diff --git a/changelog.md b/changelog.md index 7eb08a9..432df6d 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2026-01-20 - 1.4.0 - feat(tsdocker) +add multi-registry and multi-arch Docker build/push/pull manager, registry storage, Dockerfile handling, and new CLI commands + +- Introduce TsDockerManager orchestrator to discover, sort, build, test, push and pull Dockerfiles +- Add Dockerfile class with dependency-aware build order, buildx support, push/pull and test flows (new large module) +- Add DockerRegistry and RegistryStorage classes to manage registry credentials, login/logout and environment loading +- Add CLI commands: build, push, pull, test, login, list (and integrate TsDockerManager into CLI) +- Extend configuration (ITsDockerConfig) with registries, registryRepoMap, buildArgEnvMap, platforms, push and testDir; re-export as IConfig for backwards compatibility +- Add @push.rocks/lik to dependencies and import it in tsdocker.plugins +- Remove legacy speedtest command and related package.json script +- Update README and readme.hints with new features, configuration examples and command list + ## 2026-01-19 - 1.3.0 - feat(packaging) Rename package scope to @git.zone and migrate to ESM; rename CLI/config keys, update entrypoints and imports, bump Node requirement to 18, and adjust scripts/dependencies diff --git a/package.json b/package.json index d386ef2..4be4e7d 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "build": "(tsbuild)", "testIntegration": "(npm run clean && npm run setupCheck && npm run testStandard)", "testStandard": "(cd test/ && tsx ../ts/index.ts)", - "testSpeed": "(cd test/ && tsx ../ts/index.ts speedtest)", "testClean": "(cd test/ && tsx ../ts/index.ts clean --all)", "testVscode": "(cd test/ && tsx ../ts/index.ts vscode)", "clean": "(rm -rf test/)", @@ -41,6 +40,7 @@ "@types/node": "^25.0.9" }, "dependencies": { + "@push.rocks/lik": "^6.2.2", "@push.rocks/npmextra": "^5.3.3", "@push.rocks/projectinfo": "^5.0.2", "@push.rocks/qenv": "^6.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 869a907..71aa55f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@push.rocks/lik': + specifier: ^6.2.2 + version: 6.2.2 '@push.rocks/npmextra': specifier: ^5.3.3 version: 5.3.3 diff --git a/readme.hints.md b/readme.hints.md index de3de76..0284638 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -2,39 +2,108 @@ ## Module Purpose -tsdocker is a tool for developing npm modules cross-platform using Docker. It allows testing in clean, reproducible Linux environments locally. +tsdocker is a comprehensive Docker development and building tool. It provides: +- Testing npm modules in clean Docker environments (legacy feature) +- Building Dockerfiles with dependency ordering +- Multi-registry push/pull support +- Multi-architecture builds (amd64/arm64) -## Recent Upgrades (2025-11-22) +## New CLI Commands (2026-01-19) -- Updated all @git.zone/_ dependencies to @git.zone/_ scope (latest versions) -- Updated all @pushrocks/_ dependencies to @push.rocks/_ scope (latest versions) -- Migrated from smartfile v8 to smartfs v1.1.0 - - All filesystem operations now use smartfs fluent API - - Operations are now async (smartfs is async-only) -- Updated dev dependencies: - - @git.zone/tsbuild: ^3.1.0 - - @git.zone/tsrun: ^2.0.0 - - @git.zone/tstest: ^3.1.3 -- Removed @pushrocks/tapbundle (now use @git.zone/tstest/tapbundle) -- Updated @types/node to ^22.10.2 -- Removed tslint and tslint-config-prettier (no longer needed) +| Command | Description | +|---------|-------------| +| `tsdocker` | Run tests in container (legacy default behavior) | +| `tsdocker build` | Build all Dockerfiles with dependency ordering | +| `tsdocker push [registry]` | Push images to configured registries | +| `tsdocker pull ` | Pull images from registry | +| `tsdocker test` | Run container tests (test scripts) | +| `tsdocker login` | Login to configured registries | +| `tsdocker list` | List discovered Dockerfiles and dependencies | +| `tsdocker clean --all` | Clean up Docker environment | +| `tsdocker vscode` | Start VS Code in Docker | -## SmartFS Migration Details +## Configuration -The following operations were converted: +Configure in `package.json` under `@git.zone/tsdocker`: -- `smartfile.fs.fileExistsSync()` → Node.js `fs.existsSync()` (for sync needs) -- `smartfile.fs.ensureDirSync()` → Node.js `fs.mkdirSync(..., { recursive: true })` -- `smartfile.memory.toFsSync()` → `smartfs.file(path).write(content)` (async) -- `smartfile.fs.removeSync()` → `smartfs.file(path).delete()` (async) +```json +{ + "@git.zone/tsdocker": { + "registries": ["registry.gitlab.com", "docker.io"], + "registryRepoMap": { + "registry.gitlab.com": "host.today/ht-docker-node" + }, + "buildArgEnvMap": { + "NODE_VERSION": "NODE_VERSION" + }, + "platforms": ["linux/amd64", "linux/arm64"], + "push": false, + "testDir": "./test" + } +} +``` -## Test Status +### Configuration Options -- Build: ✅ Passes -- The integration test requires cloning an external test repository (sandbox-npmts) -- The external test repo uses top-level await which requires ESM module handling -- This is not a tsdocker issue but rather the test repository's structure +- `baseImage`: Base Docker image for testing (legacy) +- `command`: Command to run in container (legacy) +- `dockerSock`: Mount Docker socket (legacy) +- `registries`: Array of registry URLs to push to +- `registryRepoMap`: Map registry URLs to different repo paths +- `buildArgEnvMap`: Map Docker build ARGs to environment variables +- `platforms`: Target architectures for buildx +- `push`: Auto-push after build +- `testDir`: Directory containing test scripts + +## Registry Authentication + +Set environment variables for registry login: + +```bash +# Pipe-delimited format (numbered 1-10) +export DOCKER_REGISTRY_1="registry.gitlab.com|username|password" +export DOCKER_REGISTRY_2="docker.io|username|password" + +# Or individual registry format +export DOCKER_REGISTRY_URL="registry.gitlab.com" +export DOCKER_REGISTRY_USER="username" +export DOCKER_REGISTRY_PASSWORD="password" +``` + +## File Structure + +``` +ts/ +├── index.ts (entry point) +├── tsdocker.cli.ts (CLI commands) +├── tsdocker.config.ts (configuration) +├── tsdocker.plugins.ts (plugin imports) +├── tsdocker.docker.ts (legacy test runner) +├── tsdocker.snippets.ts (Dockerfile generation) +├── classes.dockerfile.ts (Dockerfile management) +├── classes.dockerregistry.ts (registry authentication) +├── classes.registrystorage.ts (registry storage) +├── classes.tsdockermanager.ts (orchestrator) +└── interfaces/ + └── index.ts (type definitions) +``` ## Dependencies -All dependencies are now at their latest versions compatible with Node.js without introducing new Node.js-specific dependencies. +- `@push.rocks/lik`: Object mapping utilities +- `@push.rocks/smartfs`: Filesystem operations +- `@push.rocks/smartshell`: Shell command execution +- `@push.rocks/smartcli`: CLI framework +- `@push.rocks/projectinfo`: Project metadata + +## Build Status + +- Build: ✅ Passes +- Legacy test functionality preserved +- New Docker build functionality added + +## Previous Upgrades (2025-11-22) + +- Updated all @git.zone/_ dependencies to @git.zone/_ scope +- Updated all @pushrocks/_ dependencies to @push.rocks/_ scope +- Migrated from smartfile v8 to smartfs v1.1.0 diff --git a/readme.md b/readme.md index f5f7921..effcf62 100644 --- a/readme.md +++ b/readme.md @@ -128,14 +128,6 @@ tsdocker vscode Launches a containerized VS Code instance accessible via browser at `testing-vscode.git.zone:8443`. -### Speed Test - -```bash -tsdocker speedtest -``` - -Runs a network speed test inside a Docker container. - ## Advanced Usage ### Docker-in-Docker Testing diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index c0b4f12..e8b44ed 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@git.zone/tsdocker', - version: '1.3.0', + version: '1.4.0', description: 'develop npm modules cross platform with docker' } diff --git a/ts/classes.dockerfile.ts b/ts/classes.dockerfile.ts new file mode 100644 index 0000000..5d4ae04 --- /dev/null +++ b/ts/classes.dockerfile.ts @@ -0,0 +1,462 @@ +import * as plugins from './tsdocker.plugins.js'; +import * as paths from './tsdocker.paths.js'; +import { logger } from './tsdocker.logging.js'; +import { DockerRegistry } from './classes.dockerregistry.js'; +import type { IDockerfileOptions, ITsDockerConfig } from './interfaces/index.js'; +import type { TsDockerManager } from './classes.tsdockermanager.js'; + +const smartshellInstance = new plugins.smartshell.Smartshell({ + executor: 'bash', +}); + +/** + * Class Dockerfile represents a Dockerfile on disk + */ +export class Dockerfile { + // STATIC METHODS + + /** + * Creates instances of class Dockerfile for all Dockerfiles in cwd + */ + public static async readDockerfiles(managerRef: TsDockerManager): Promise { + const entries = await plugins.smartfs.directory(paths.cwd).filter('Dockerfile*').list(); + const fileTree = entries + .filter(entry => entry.isFile) + .map(entry => plugins.path.join(paths.cwd, entry.name)); + + const readDockerfilesArray: Dockerfile[] = []; + logger.log('info', `found ${fileTree.length} Dockerfiles:`); + console.log(fileTree); + + for (const dockerfilePath of fileTree) { + const myDockerfile = new Dockerfile(managerRef, { + filePath: dockerfilePath, + read: true, + }); + readDockerfilesArray.push(myDockerfile); + } + + return readDockerfilesArray; + } + + /** + * Sorts Dockerfiles into a build order based on dependencies (topological sort) + */ + public static async sortDockerfiles(dockerfiles: Dockerfile[]): Promise { + logger.log('info', 'Sorting Dockerfiles based on dependencies...'); + + // Map from cleanTag to Dockerfile instance for quick lookup + const tagToDockerfile = new Map(); + dockerfiles.forEach((dockerfile) => { + tagToDockerfile.set(dockerfile.cleanTag, dockerfile); + }); + + // Build the dependency graph + const graph = new Map(); + dockerfiles.forEach((dockerfile) => { + const dependencies: Dockerfile[] = []; + const baseImage = dockerfile.baseImage; + + // Check if the baseImage is among the local Dockerfiles + if (tagToDockerfile.has(baseImage)) { + const baseDockerfile = tagToDockerfile.get(baseImage)!; + dependencies.push(baseDockerfile); + dockerfile.localBaseImageDependent = true; + dockerfile.localBaseDockerfile = baseDockerfile; + } + + graph.set(dockerfile, dependencies); + }); + + // Perform topological sort + const sortedDockerfiles: Dockerfile[] = []; + const visited = new Set(); + const tempMarked = new Set(); + + const visit = (dockerfile: Dockerfile) => { + if (tempMarked.has(dockerfile)) { + throw new Error(`Circular dependency detected involving ${dockerfile.cleanTag}`); + } + if (!visited.has(dockerfile)) { + tempMarked.add(dockerfile); + const dependencies = graph.get(dockerfile) || []; + dependencies.forEach((dep) => visit(dep)); + tempMarked.delete(dockerfile); + visited.add(dockerfile); + sortedDockerfiles.push(dockerfile); + } + }; + + try { + dockerfiles.forEach((dockerfile) => { + if (!visited.has(dockerfile)) { + visit(dockerfile); + } + }); + } catch (error) { + logger.log('error', (error as Error).message); + throw error; + } + + // Log the sorted order + sortedDockerfiles.forEach((dockerfile, index) => { + logger.log( + 'info', + `Build order ${index + 1}: ${dockerfile.cleanTag} with base image ${dockerfile.baseImage}` + ); + }); + + return sortedDockerfiles; + } + + /** + * Maps local Dockerfiles dependencies to the corresponding Dockerfile class instances + */ + public static async mapDockerfiles(sortedDockerfileArray: Dockerfile[]): Promise { + sortedDockerfileArray.forEach((dockerfileArg) => { + if (dockerfileArg.localBaseImageDependent) { + sortedDockerfileArray.forEach((dockfile2: Dockerfile) => { + if (dockfile2.cleanTag === dockerfileArg.baseImage) { + dockerfileArg.localBaseDockerfile = dockfile2; + } + }); + } + }); + return sortedDockerfileArray; + } + + /** + * Builds the corresponding real docker image for each Dockerfile class instance + */ + public static async buildDockerfiles(sortedArrayArg: Dockerfile[]): Promise { + for (const dockerfileArg of sortedArrayArg) { + await dockerfileArg.build(); + } + return sortedArrayArg; + } + + /** + * Tests all Dockerfiles by calling Dockerfile.test() + */ + public static async testDockerfiles(sortedArrayArg: Dockerfile[]): Promise { + for (const dockerfileArg of sortedArrayArg) { + await dockerfileArg.test(); + } + return sortedArrayArg; + } + + /** + * Returns a version for a docker file + * Dockerfile_latest -> latest + * Dockerfile_v1.0.0 -> v1.0.0 + * Dockerfile -> latest + */ + public static dockerFileVersion( + dockerfileInstanceArg: Dockerfile, + dockerfileNameArg: string + ): string { + let versionString: string; + const versionRegex = /Dockerfile_(.+)$/; + const regexResultArray = versionRegex.exec(dockerfileNameArg); + if (regexResultArray && regexResultArray.length === 2) { + versionString = regexResultArray[1]; + } else { + versionString = 'latest'; + } + + // Replace ##version## placeholder with actual package version if available + if (dockerfileInstanceArg.managerRef?.projectInfo?.npm?.version) { + versionString = versionString.replace( + '##version##', + dockerfileInstanceArg.managerRef.projectInfo.npm.version + ); + } + + return versionString; + } + + /** + * Extracts the base image from a Dockerfile content + * Handles ARG substitution for variable base images + */ + public static dockerBaseImage(dockerfileContentArg: string): string { + const lines = dockerfileContentArg.split(/\r?\n/); + const args: { [key: string]: string } = {}; + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Skip empty lines and comments + if (trimmedLine === '' || trimmedLine.startsWith('#')) { + continue; + } + + // Match ARG instructions + const argMatch = trimmedLine.match(/^ARG\s+([^\s=]+)(?:=(.*))?$/i); + if (argMatch) { + const argName = argMatch[1]; + const argValue = argMatch[2] !== undefined ? argMatch[2] : process.env[argName] || ''; + args[argName] = argValue; + continue; + } + + // Match FROM instructions + const fromMatch = trimmedLine.match(/^FROM\s+(.+?)(?:\s+AS\s+[^\s]+)?$/i); + if (fromMatch) { + let baseImage = fromMatch[1].trim(); + + // Substitute variables in the base image name + baseImage = Dockerfile.substituteVariables(baseImage, args); + + return baseImage; + } + } + + throw new Error('No FROM instruction found in Dockerfile'); + } + + /** + * Substitutes variables in a string, supporting default values like ${VAR:-default} + */ + private static substituteVariables(str: string, vars: { [key: string]: string }): string { + return str.replace(/\${([^}:]+)(:-([^}]+))?}/g, (_, varName, __, defaultValue) => { + if (vars[varName] !== undefined) { + return vars[varName]; + } else if (defaultValue !== undefined) { + return defaultValue; + } else { + return ''; + } + }); + } + + /** + * Returns the docker tag string for a given registry and repo + */ + public static getDockerTagString( + managerRef: TsDockerManager, + registryArg: string, + repoArg: string, + versionArg: string, + suffixArg?: string + ): string { + // Determine whether the repo should be mapped according to the registry + const config = managerRef.config; + const mappedRepo = config.registryRepoMap?.[registryArg]; + const repo = mappedRepo || repoArg; + + // Determine whether the version contains a suffix + let version = versionArg; + if (suffixArg) { + version = versionArg + '_' + suffixArg; + } + + const tagString = `${registryArg}/${repo}:${version}`; + return tagString; + } + + /** + * Gets build args from environment variable mapping + */ + public static async getDockerBuildArgs(managerRef: TsDockerManager): Promise { + logger.log('info', 'checking for env vars to be supplied to the docker build'); + let buildArgsString: string = ''; + const config = managerRef.config; + + if (config.buildArgEnvMap) { + for (const dockerArgKey of Object.keys(config.buildArgEnvMap)) { + const dockerArgOuterEnvVar = config.buildArgEnvMap[dockerArgKey]; + logger.log( + 'note', + `docker ARG "${dockerArgKey}" maps to outer env var "${dockerArgOuterEnvVar}"` + ); + const targetValue = process.env[dockerArgOuterEnvVar]; + if (targetValue) { + buildArgsString = `${buildArgsString} --build-arg ${dockerArgKey}="${targetValue}"`; + } + } + } + return buildArgsString; + } + + // INSTANCE PROPERTIES + public managerRef: TsDockerManager; + public filePath!: string; + public repo: string; + public version: string; + public cleanTag: string; + public buildTag: string; + public pushTag!: string; + public containerName: string; + public content!: string; + public baseImage: string; + public localBaseImageDependent: boolean; + public localBaseDockerfile!: Dockerfile; + + constructor(managerRefArg: TsDockerManager, options: IDockerfileOptions) { + this.managerRef = managerRefArg; + this.filePath = options.filePath!; + + // Build repo name from project info or directory name + const projectInfo = this.managerRef.projectInfo; + if (projectInfo?.npm?.name) { + // Use package name, removing scope if present + const packageName = projectInfo.npm.name.replace(/^@[^/]+\//, ''); + this.repo = packageName; + } else { + // Fallback to directory name + this.repo = plugins.path.basename(paths.cwd); + } + + this.version = Dockerfile.dockerFileVersion(this, plugins.path.parse(this.filePath).base); + this.cleanTag = this.repo + ':' + this.version; + this.buildTag = this.cleanTag; + this.containerName = 'dockerfile-' + this.version; + + if (options.filePath && options.read) { + const fs = require('fs'); + this.content = fs.readFileSync(plugins.path.resolve(options.filePath), 'utf-8'); + } else if (options.fileContents) { + this.content = options.fileContents; + } + + this.baseImage = Dockerfile.dockerBaseImage(this.content); + this.localBaseImageDependent = false; + } + + /** + * Builds the Dockerfile + */ + public async build(): Promise { + logger.log('info', 'now building Dockerfile for ' + this.cleanTag); + const buildArgsString = await Dockerfile.getDockerBuildArgs(this.managerRef); + const config = this.managerRef.config; + + let buildCommand: string; + + // Check if multi-platform build is needed + if (config.platforms && config.platforms.length > 1) { + // Multi-platform build using buildx + const platformString = config.platforms.join(','); + buildCommand = `docker buildx build --platform ${platformString} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`; + + if (config.push) { + buildCommand += ' --push'; + } else { + buildCommand += ' --load'; + } + } else { + // Standard build + const versionLabel = this.managerRef.projectInfo?.npm?.version || 'unknown'; + buildCommand = `docker build --label="version=${versionLabel}" -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`; + } + + const result = await smartshellInstance.exec(buildCommand); + if (result.exitCode !== 0) { + logger.log('error', `Build failed for ${this.cleanTag}`); + console.log(result.stdout); + throw new Error(`Build failed for ${this.cleanTag}`); + } + + logger.log('ok', `Built ${this.cleanTag}`); + } + + /** + * Pushes the Dockerfile to a registry + */ + public async push(dockerRegistryArg: DockerRegistry, versionSuffix?: string): Promise { + this.pushTag = Dockerfile.getDockerTagString( + this.managerRef, + dockerRegistryArg.registryUrl, + this.repo, + this.version, + versionSuffix + ); + + await smartshellInstance.exec(`docker tag ${this.buildTag} ${this.pushTag}`); + const pushResult = await smartshellInstance.exec(`docker push ${this.pushTag}`); + + if (pushResult.exitCode !== 0) { + logger.log('error', `Push failed for ${this.pushTag}`); + throw new Error(`Push failed for ${this.pushTag}`); + } + + // Get image digest + const inspectResult = await smartshellInstance.exec( + `docker inspect --format="{{index .RepoDigests 0}}" ${this.pushTag}` + ); + + if (inspectResult.exitCode === 0 && inspectResult.stdout.includes('@')) { + const imageDigest = inspectResult.stdout.split('@')[1]?.trim(); + console.log(`The image ${this.pushTag} has digest ${imageDigest}`); + } + + logger.log('ok', `Pushed ${this.pushTag}`); + } + + /** + * Pulls the Dockerfile from a registry + */ + public async pull(registryArg: DockerRegistry, versionSuffixArg?: string): Promise { + const pullTag = Dockerfile.getDockerTagString( + this.managerRef, + registryArg.registryUrl, + this.repo, + this.version, + versionSuffixArg + ); + + await smartshellInstance.exec(`docker pull ${pullTag}`); + await smartshellInstance.exec(`docker tag ${pullTag} ${this.buildTag}`); + + logger.log('ok', `Pulled and tagged ${pullTag} as ${this.buildTag}`); + } + + /** + * Tests the Dockerfile by running a test script if it exists + */ + public async test(): Promise { + const testDir = this.managerRef.config.testDir || plugins.path.join(paths.cwd, 'test'); + const testFile = plugins.path.join(testDir, 'test_' + this.version + '.sh'); + + const fs = require('fs'); + const testFileExists = fs.existsSync(testFile); + + if (testFileExists) { + logger.log('info', `Running tests for ${this.cleanTag}`); + + // Run tests in container + await smartshellInstance.exec( + `docker run --name tsdocker_test_container --entrypoint="bash" ${this.buildTag} -c "mkdir /tsdocker_test"` + ); + await smartshellInstance.exec(`docker cp ${testFile} tsdocker_test_container:/tsdocker_test/test.sh`); + await smartshellInstance.exec(`docker commit tsdocker_test_container tsdocker_test_image`); + + const testResult = await smartshellInstance.exec( + `docker run --entrypoint="bash" tsdocker_test_image -x /tsdocker_test/test.sh` + ); + + // Cleanup + await smartshellInstance.exec(`docker rm tsdocker_test_container`); + await smartshellInstance.exec(`docker rmi --force tsdocker_test_image`); + + if (testResult.exitCode !== 0) { + throw new Error(`Tests failed for ${this.cleanTag}`); + } + + logger.log('ok', `Tests passed for ${this.cleanTag}`); + } else { + logger.log('warn', `Skipping tests for ${this.cleanTag} because no test file was found at ${testFile}`); + } + } + + /** + * Gets the ID of a built Docker image + */ + public async getId(): Promise { + const result = await smartshellInstance.exec( + 'docker inspect --type=image --format="{{.Id}}" ' + this.buildTag + ); + return result.stdout.trim(); + } +} diff --git a/ts/classes.dockerregistry.ts b/ts/classes.dockerregistry.ts new file mode 100644 index 0000000..22e873d --- /dev/null +++ b/ts/classes.dockerregistry.ts @@ -0,0 +1,91 @@ +import * as plugins from './tsdocker.plugins.js'; +import { logger } from './tsdocker.logging.js'; +import type { IDockerRegistryOptions } from './interfaces/index.js'; + +const smartshellInstance = new plugins.smartshell.Smartshell({ + executor: 'bash', +}); + +/** + * Represents a Docker registry with authentication capabilities + */ +export class DockerRegistry { + public registryUrl: string; + public username: string; + public password: string; + + constructor(optionsArg: IDockerRegistryOptions) { + this.registryUrl = optionsArg.registryUrl; + this.username = optionsArg.username; + this.password = optionsArg.password; + logger.log('info', `created DockerRegistry for ${this.registryUrl}`); + } + + /** + * Creates a DockerRegistry instance from a pipe-delimited environment string + * Format: "registryUrl|username|password" + */ + public static fromEnvString(envString: string): DockerRegistry { + const dockerRegexResultArray = envString.split('|'); + if (dockerRegexResultArray.length !== 3) { + logger.log('error', 'malformed docker env var...'); + throw new Error('malformed docker env var, expected format: registryUrl|username|password'); + } + const registryUrl = dockerRegexResultArray[0].replace('https://', '').replace('http://', ''); + const username = dockerRegexResultArray[1]; + const password = dockerRegexResultArray[2]; + return new DockerRegistry({ + registryUrl: registryUrl, + username: username, + password: password, + }); + } + + /** + * Creates a DockerRegistry from environment variables + * Looks for DOCKER_REGISTRY, DOCKER_REGISTRY_USER, DOCKER_REGISTRY_PASSWORD + * Or for a specific registry: DOCKER_REGISTRY_, etc. + */ + public static fromEnv(registryName?: string): DockerRegistry | null { + const prefix = registryName ? `DOCKER_REGISTRY_${registryName.toUpperCase()}_` : 'DOCKER_REGISTRY_'; + + const registryUrl = process.env[`${prefix}URL`] || process.env['DOCKER_REGISTRY']; + const username = process.env[`${prefix}USER`] || process.env['DOCKER_REGISTRY_USER']; + const password = process.env[`${prefix}PASSWORD`] || process.env['DOCKER_REGISTRY_PASSWORD']; + + if (!registryUrl || !username || !password) { + return null; + } + + return new DockerRegistry({ + registryUrl: registryUrl.replace('https://', '').replace('http://', ''), + username, + password, + }); + } + + /** + * Logs in to the Docker registry + */ + public async login(): Promise { + if (this.registryUrl === 'docker.io') { + await smartshellInstance.exec(`docker login -u ${this.username} -p ${this.password}`); + logger.log('info', 'Logged in to standard docker hub'); + } else { + await smartshellInstance.exec(`docker login -u ${this.username} -p ${this.password} ${this.registryUrl}`); + } + logger.log('ok', `docker authenticated for ${this.registryUrl}!`); + } + + /** + * Logs out from the Docker registry + */ + public async logout(): Promise { + if (this.registryUrl === 'docker.io') { + await smartshellInstance.exec('docker logout'); + } else { + await smartshellInstance.exec(`docker logout ${this.registryUrl}`); + } + logger.log('info', `logged out from ${this.registryUrl}`); + } +} diff --git a/ts/classes.registrystorage.ts b/ts/classes.registrystorage.ts new file mode 100644 index 0000000..e3fa094 --- /dev/null +++ b/ts/classes.registrystorage.ts @@ -0,0 +1,83 @@ +import * as plugins from './tsdocker.plugins.js'; +import { logger } from './tsdocker.logging.js'; +import { DockerRegistry } from './classes.dockerregistry.js'; + +/** + * Storage class for managing multiple Docker registries + */ +export class RegistryStorage { + public objectMap = new plugins.lik.ObjectMap(); + + constructor() { + // Nothing here + } + + /** + * Adds a registry to the storage + */ + public addRegistry(registryArg: DockerRegistry): void { + this.objectMap.add(registryArg); + } + + /** + * Gets a registry by its URL + */ + public getRegistryByUrl(registryUrlArg: string): DockerRegistry | undefined { + return this.objectMap.findSync((registryArg) => { + return registryArg.registryUrl === registryUrlArg; + }); + } + + /** + * Gets all registries + */ + public getAllRegistries(): DockerRegistry[] { + return this.objectMap.getArray(); + } + + /** + * Logs in to all registries + */ + public async loginAll(): Promise { + await this.objectMap.forEach(async (registryArg) => { + await registryArg.login(); + }); + logger.log('success', 'logged in successfully into all available DockerRegistries!'); + } + + /** + * Logs out from all registries + */ + public async logoutAll(): Promise { + await this.objectMap.forEach(async (registryArg) => { + await registryArg.logout(); + }); + logger.log('info', 'logged out from all DockerRegistries'); + } + + /** + * Loads registries from environment variables + * Looks for DOCKER_REGISTRY_1, DOCKER_REGISTRY_2, etc. (pipe-delimited format) + * Or individual registries like DOCKER_REGISTRY_GITLAB_URL, etc. + */ + public loadFromEnv(): void { + // Check for numbered registry env vars (pipe-delimited format) + for (let i = 1; i <= 10; i++) { + const envVar = process.env[`DOCKER_REGISTRY_${i}`]; + if (envVar) { + try { + const registry = DockerRegistry.fromEnvString(envVar); + this.addRegistry(registry); + } catch (err) { + logger.log('warn', `Failed to parse DOCKER_REGISTRY_${i}: ${(err as Error).message}`); + } + } + } + + // Check for default registry + const defaultRegistry = DockerRegistry.fromEnv(); + if (defaultRegistry) { + this.addRegistry(defaultRegistry); + } + } +} diff --git a/ts/classes.tsdockermanager.ts b/ts/classes.tsdockermanager.ts new file mode 100644 index 0000000..23851ed --- /dev/null +++ b/ts/classes.tsdockermanager.ts @@ -0,0 +1,254 @@ +import * as plugins from './tsdocker.plugins.js'; +import * as paths from './tsdocker.paths.js'; +import { logger } from './tsdocker.logging.js'; +import { Dockerfile } from './classes.dockerfile.js'; +import { DockerRegistry } from './classes.dockerregistry.js'; +import { RegistryStorage } from './classes.registrystorage.js'; +import type { ITsDockerConfig } from './interfaces/index.js'; + +const smartshellInstance = new plugins.smartshell.Smartshell({ + executor: 'bash', +}); + +/** + * Main orchestrator class for Docker operations + */ +export class TsDockerManager { + public registryStorage: RegistryStorage; + public config: ITsDockerConfig; + public projectInfo: any; + private dockerfiles: Dockerfile[] = []; + + constructor(config: ITsDockerConfig) { + this.config = config; + this.registryStorage = new RegistryStorage(); + } + + /** + * Prepares the manager by loading project info and registries + */ + public async prepare(): Promise { + // Load project info + try { + const projectinfoInstance = new plugins.projectinfo.ProjectInfo(paths.cwd); + this.projectInfo = { + npm: { + name: projectinfoInstance.npm.name, + version: projectinfoInstance.npm.version, + }, + }; + } catch (err) { + logger.log('warn', 'Could not load project info'); + this.projectInfo = null; + } + + // Load registries from environment + this.registryStorage.loadFromEnv(); + + // Add registries from config if specified + if (this.config.registries) { + for (const registryUrl of this.config.registries) { + // Check if already loaded from env + if (!this.registryStorage.getRegistryByUrl(registryUrl)) { + // Try to load credentials for this registry from env + const envVarName = registryUrl.replace(/\./g, '_').toUpperCase(); + const envString = process.env[`DOCKER_REGISTRY_${envVarName}`]; + if (envString) { + try { + const registry = DockerRegistry.fromEnvString(envString); + this.registryStorage.addRegistry(registry); + } catch (err) { + logger.log('warn', `Could not load credentials for registry ${registryUrl}`); + } + } + } + } + } + + logger.log('info', `Prepared TsDockerManager with ${this.registryStorage.getAllRegistries().length} registries`); + } + + /** + * Logs in to all configured registries + */ + public async login(): Promise { + if (this.registryStorage.getAllRegistries().length === 0) { + logger.log('warn', 'No registries configured'); + return; + } + await this.registryStorage.loginAll(); + } + + /** + * Discovers and sorts Dockerfiles in the current directory + */ + public async discoverDockerfiles(): Promise { + this.dockerfiles = await Dockerfile.readDockerfiles(this); + this.dockerfiles = await Dockerfile.sortDockerfiles(this.dockerfiles); + this.dockerfiles = await Dockerfile.mapDockerfiles(this.dockerfiles); + return this.dockerfiles; + } + + /** + * Builds all discovered Dockerfiles in dependency order + */ + public async build(): Promise { + if (this.dockerfiles.length === 0) { + await this.discoverDockerfiles(); + } + + if (this.dockerfiles.length === 0) { + logger.log('warn', 'No Dockerfiles found'); + return []; + } + + // Check if buildx is needed + if (this.config.platforms && this.config.platforms.length > 1) { + await this.ensureBuildx(); + } + + logger.log('info', `Building ${this.dockerfiles.length} Dockerfiles...`); + await Dockerfile.buildDockerfiles(this.dockerfiles); + logger.log('success', 'All Dockerfiles built successfully'); + + return this.dockerfiles; + } + + /** + * Ensures Docker buildx is set up for multi-architecture builds + */ + private async ensureBuildx(): Promise { + logger.log('info', 'Setting up Docker buildx for multi-platform builds...'); + + // Check if a buildx builder exists + const inspectResult = await smartshellInstance.exec('docker buildx inspect tsdocker-builder 2>/dev/null'); + + if (inspectResult.exitCode !== 0) { + // Create a new buildx builder + logger.log('info', 'Creating new buildx builder...'); + await smartshellInstance.exec('docker buildx create --name tsdocker-builder --use'); + await smartshellInstance.exec('docker buildx inspect --bootstrap'); + } else { + // Use existing builder + await smartshellInstance.exec('docker buildx use tsdocker-builder'); + } + + logger.log('ok', 'Docker buildx ready'); + } + + /** + * Pushes all built images to specified registries + */ + public async push(registryUrls?: string[]): Promise { + if (this.dockerfiles.length === 0) { + await this.discoverDockerfiles(); + } + + if (this.dockerfiles.length === 0) { + logger.log('warn', 'No Dockerfiles found to push'); + return; + } + + // Determine which registries to push to + let registriesToPush: DockerRegistry[] = []; + + if (registryUrls && registryUrls.length > 0) { + // Push to specified registries + for (const url of registryUrls) { + const registry = this.registryStorage.getRegistryByUrl(url); + if (registry) { + registriesToPush.push(registry); + } else { + logger.log('warn', `Registry ${url} not found in storage`); + } + } + } else { + // Push to all configured registries + registriesToPush = this.registryStorage.getAllRegistries(); + } + + if (registriesToPush.length === 0) { + logger.log('warn', 'No registries available to push to'); + return; + } + + // Push each Dockerfile to each registry + for (const dockerfile of this.dockerfiles) { + for (const registry of registriesToPush) { + await dockerfile.push(registry); + } + } + + logger.log('success', 'All images pushed successfully'); + } + + /** + * Pulls images from a specified registry + */ + public async pull(registryUrl: string): Promise { + if (this.dockerfiles.length === 0) { + await this.discoverDockerfiles(); + } + + const registry = this.registryStorage.getRegistryByUrl(registryUrl); + if (!registry) { + throw new Error(`Registry ${registryUrl} not found`); + } + + for (const dockerfile of this.dockerfiles) { + await dockerfile.pull(registry); + } + + logger.log('success', 'All images pulled successfully'); + } + + /** + * Runs tests for all Dockerfiles + */ + public async test(): Promise { + if (this.dockerfiles.length === 0) { + await this.discoverDockerfiles(); + } + + if (this.dockerfiles.length === 0) { + logger.log('warn', 'No Dockerfiles found to test'); + return; + } + + await Dockerfile.testDockerfiles(this.dockerfiles); + logger.log('success', 'All tests completed'); + } + + /** + * Lists all discovered Dockerfiles and their info + */ + public async list(): Promise { + if (this.dockerfiles.length === 0) { + await this.discoverDockerfiles(); + } + + console.log('\nDiscovered Dockerfiles:'); + console.log('========================\n'); + + for (let i = 0; i < this.dockerfiles.length; i++) { + const df = this.dockerfiles[i]; + console.log(`${i + 1}. ${df.filePath}`); + console.log(` Tag: ${df.cleanTag}`); + console.log(` Base Image: ${df.baseImage}`); + console.log(` Version: ${df.version}`); + if (df.localBaseImageDependent) { + console.log(` Depends on: ${df.localBaseDockerfile?.cleanTag}`); + } + console.log(''); + } + + return this.dockerfiles; + } + + /** + * Gets the cached Dockerfiles (after discovery) + */ + public getDockerfiles(): Dockerfile[] { + return this.dockerfiles; + } +} diff --git a/ts/interfaces/index.ts b/ts/interfaces/index.ts new file mode 100644 index 0000000..895b86a --- /dev/null +++ b/ts/interfaces/index.ts @@ -0,0 +1,70 @@ +/** + * Configuration interface for tsdocker + * Extends legacy config with new Docker build capabilities + */ +export interface ITsDockerConfig { + // Legacy (backward compatible) + baseImage: string; + command: string; + dockerSock: boolean; + keyValueObject: { [key: string]: any }; + + // New Docker build config + registries?: string[]; + registryRepoMap?: { [registry: string]: string }; + buildArgEnvMap?: { [dockerArg: string]: string }; + platforms?: string[]; // ['linux/amd64', 'linux/arm64'] + push?: boolean; + testDir?: string; +} + +/** + * Options for constructing a DockerRegistry + */ +export interface IDockerRegistryOptions { + registryUrl: string; + username: string; + password: string; +} + +/** + * Information about a discovered Dockerfile + */ +export interface IDockerfileInfo { + filePath: string; + fileName: string; + version: string; + baseImage: string; + buildTag: string; + localBaseImageDependent: boolean; +} + +/** + * Options for creating a Dockerfile instance + */ +export interface IDockerfileOptions { + filePath?: string; + fileContents?: string; + read?: boolean; +} + +/** + * Result from a Docker build operation + */ +export interface IBuildResult { + success: boolean; + tag: string; + duration?: number; + error?: string; +} + +/** + * Result from a Docker push operation + */ +export interface IPushResult { + success: boolean; + registry: string; + tag: string; + digest?: string; + error?: string; +} diff --git a/ts/tsdocker.cli.ts b/ts/tsdocker.cli.ts index 4b25511..993b61b 100644 --- a/ts/tsdocker.cli.ts +++ b/ts/tsdocker.cli.ts @@ -6,10 +6,12 @@ import * as ConfigModule from './tsdocker.config.js'; import * as DockerModule from './tsdocker.docker.js'; import { logger, ora } from './tsdocker.logging.js'; +import { TsDockerManager } from './classes.tsdockermanager.js'; const tsdockerCli = new plugins.smartcli.Smartcli(); export let run = () => { + // Default command: run tests in container (legacy behavior) tsdockerCli.standardCommand().subscribe(async argvArg => { const configArg = await ConfigModule.run().then(DockerModule.run); if (configArg.exitCode === 0) { @@ -20,6 +22,127 @@ export let run = () => { } }); + /** + * Build all Dockerfiles in dependency order + */ + tsdockerCli.addCommand('build').subscribe(async argvArg => { + try { + const config = await ConfigModule.run(); + const manager = new TsDockerManager(config); + await manager.prepare(); + await manager.build(); + logger.log('success', 'Build completed successfully'); + } catch (err) { + logger.log('error', `Build failed: ${(err as Error).message}`); + process.exit(1); + } + }); + + /** + * Push built images to configured registries + */ + tsdockerCli.addCommand('push').subscribe(async argvArg => { + try { + const config = await ConfigModule.run(); + const manager = new TsDockerManager(config); + await manager.prepare(); + + // Login first + await manager.login(); + + // Build images first (if not already built) + await manager.build(); + + // Get registry from arguments if specified + const registryArg = argvArg._[1]; // e.g., tsdocker push registry.gitlab.com + const registries = registryArg ? [registryArg] : undefined; + + await manager.push(registries); + logger.log('success', 'Push completed successfully'); + } catch (err) { + logger.log('error', `Push failed: ${(err as Error).message}`); + process.exit(1); + } + }); + + /** + * Pull images from a specified registry + */ + tsdockerCli.addCommand('pull').subscribe(async argvArg => { + try { + const registryArg = argvArg._[1]; // e.g., tsdocker pull registry.gitlab.com + if (!registryArg) { + logger.log('error', 'Registry URL required. Usage: tsdocker pull '); + process.exit(1); + } + + const config = await ConfigModule.run(); + const manager = new TsDockerManager(config); + await manager.prepare(); + + // Login first + await manager.login(); + + await manager.pull(registryArg); + logger.log('success', 'Pull completed successfully'); + } catch (err) { + logger.log('error', `Pull failed: ${(err as Error).message}`); + process.exit(1); + } + }); + + /** + * Run container tests for all Dockerfiles + */ + tsdockerCli.addCommand('test').subscribe(async argvArg => { + try { + const config = await ConfigModule.run(); + const manager = new TsDockerManager(config); + await manager.prepare(); + + // Build images first + await manager.build(); + + // Run tests + await manager.test(); + logger.log('success', 'Tests completed successfully'); + } catch (err) { + logger.log('error', `Tests failed: ${(err as Error).message}`); + process.exit(1); + } + }); + + /** + * Login to configured registries + */ + tsdockerCli.addCommand('login').subscribe(async argvArg => { + try { + const config = await ConfigModule.run(); + const manager = new TsDockerManager(config); + await manager.prepare(); + await manager.login(); + logger.log('success', 'Login completed successfully'); + } catch (err) { + logger.log('error', `Login failed: ${(err as Error).message}`); + process.exit(1); + } + }); + + /** + * List discovered Dockerfiles and their dependencies + */ + tsdockerCli.addCommand('list').subscribe(async argvArg => { + try { + const config = await ConfigModule.run(); + const manager = new TsDockerManager(config); + await manager.prepare(); + await manager.list(); + } catch (err) { + logger.log('error', `List failed: ${(err as Error).message}`); + process.exit(1); + } + }); + /** * this command is executed inside docker and meant for use from outside docker */ @@ -62,16 +185,6 @@ export let run = () => { ora.finishSuccess('docker environment now is clean!'); }); - tsdockerCli.addCommand('speedtest').subscribe(async argvArg => { - const smartshellInstance = new plugins.smartshell.Smartshell({ - executor: 'bash' - }); - logger.log('ok', 'Starting speedtest'); - await smartshellInstance.exec( - `docker pull tianon/speedtest && docker run --rm tianon/speedtest --accept-license --accept-gdpr` - ); - }); - tsdockerCli.addCommand('vscode').subscribe(async argvArg => { const smartshellInstance = new plugins.smartshell.Smartshell({ executor: 'bash' diff --git a/ts/tsdocker.config.ts b/ts/tsdocker.config.ts index a57a2dc..2b27b6f 100644 --- a/ts/tsdocker.config.ts +++ b/ts/tsdocker.config.ts @@ -1,14 +1,12 @@ import * as plugins from './tsdocker.plugins.js'; import * as paths from './tsdocker.paths.js'; import * as fs from 'fs'; +import type { ITsDockerConfig } from './interfaces/index.js'; -export interface IConfig { - baseImage: string; - command: string; - dockerSock: boolean; +// Re-export ITsDockerConfig as IConfig for backward compatibility +export type IConfig = ITsDockerConfig & { exitCode?: number; - keyValueObject: {[key: string]: any}; -} +}; const getQenvKeyValueObject = async () => { let qenvKeyValueObjectArray: { [key: string]: string | number }; @@ -23,11 +21,20 @@ const getQenvKeyValueObject = async () => { const buildConfig = async (qenvKeyValueObjectArg: { [key: string]: string | number }) => { const npmextra = new plugins.npmextra.Npmextra(paths.cwd); const config = npmextra.dataFor('@git.zone/tsdocker', { + // Legacy options (backward compatible) baseImage: 'hosttoday/ht-docker-node:npmdocker', init: 'rm -rf node_nodules/ && yarn install', command: 'npmci npm test', dockerSock: false, - keyValueObject: qenvKeyValueObjectArg + keyValueObject: qenvKeyValueObjectArg, + + // New Docker build options + registries: [], + registryRepoMap: {}, + buildArgEnvMap: {}, + platforms: ['linux/amd64'], + push: false, + testDir: undefined, }); return config; }; diff --git a/ts/tsdocker.plugins.ts b/ts/tsdocker.plugins.ts index 1fe4f4a..406ebcd 100644 --- a/ts/tsdocker.plugins.ts +++ b/ts/tsdocker.plugins.ts @@ -1,4 +1,5 @@ // push.rocks scope +import * as lik from '@push.rocks/lik'; import * as npmextra from '@push.rocks/npmextra'; import * as path from 'path'; import * as projectinfo from '@push.rocks/projectinfo'; @@ -17,6 +18,7 @@ import * as smartstring from '@push.rocks/smartstring'; export const smartfs = new SmartFs(new SmartFsProviderNode()); export { + lik, npmextra, path, projectinfo,