import * as plugins from './tsdocker.plugins.js'; import * as paths from './tsdocker.paths.js'; import { logger, formatDuration } from './tsdocker.logging.js'; import { DockerRegistry } from './classes.dockerregistry.js'; import { RegistryCopy } from './classes.registrycopy.js'; import { TsDockerSession } from './classes.tsdockersession.js'; import type { IDockerfileOptions, ITsDockerConfig, IBuildCommandOptions } from './interfaces/index.js'; import type { TsDockerManager } from './classes.tsdockermanager.js'; import * as fs from 'fs'; const smartshellInstance = new plugins.smartshell.Smartshell({ executor: 'bash', }); /** * Extracts a platform string (e.g. "linux/amd64") from a buildx bracket prefix. * The prefix may be like "linux/amd64 ", "linux/amd64 stage-1 ", "stage-1 ", or "". */ function extractPlatform(prefix: string): string | null { const match = prefix.match(/linux\/\w+/); return match ? match[0] : null; } /** * 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} Dockerfile(s):`); for (const filePath of fileTree) { logger.log('info', ` ${plugins.path.basename(filePath)}`); } 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; // Extract repo:version from baseImage for comparison with cleanTag // baseImage may include a registry prefix (e.g., "host.today/repo:version") // but cleanTag is just "repo:version", so we strip the registry prefix const baseImageKey = Dockerfile.extractRepoVersion(baseImage); // Check if the baseImage is among the local Dockerfiles if (tagToDockerfile.has(baseImageKey)) { const baseDockerfile = tagToDockerfile.get(baseImageKey)!; 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) { // Extract repo:version from baseImage for comparison with cleanTag const baseImageKey = Dockerfile.extractRepoVersion(dockerfileArg.baseImage); sortedDockerfileArray.forEach((dockfile2: Dockerfile) => { if (dockfile2.cleanTag === baseImageKey) { dockerfileArg.localBaseDockerfile = dockfile2; } }); } }); return sortedDockerfileArray; } /** Local registry is always needed — it's the canonical store for all built images. */ public static needsLocalRegistry( _dockerfiles?: Dockerfile[], _options?: { platform?: string }, ): boolean { return true; } /** Starts a persistent registry:2 container with session-unique port and name. */ public static async startLocalRegistry(session: TsDockerSession, isRootless?: boolean): Promise { const { registryPort, registryHost, registryContainerName, isCI, sessionId } = session.config; // Ensure persistent storage directory exists — isolate per session in CI const registryDataDir = isCI ? plugins.path.join(paths.cwd, '.nogit', 'docker-registry', sessionId) : plugins.path.join(paths.cwd, '.nogit', 'docker-registry'); fs.mkdirSync(registryDataDir, { recursive: true }); await smartshellInstance.execSilent( `docker rm -f ${registryContainerName} 2>/dev/null || true` ); const runCmd = `docker run -d --name ${registryContainerName} -p ${registryPort}:5000 -v "${registryDataDir}:/var/lib/registry" registry:2`; let result = await smartshellInstance.execSilent(runCmd); // Port retry: if port was stolen between allocation and docker run, reallocate once if (result.exitCode !== 0 && (result.stderr || result.stdout || '').includes('port is already allocated')) { const newPort = await TsDockerSession.allocatePort(); logger.log('warn', `Port ${registryPort} taken, retrying with ${newPort}`); session.config.registryPort = newPort; session.config.registryHost = `localhost:${newPort}`; const retryCmd = `docker run -d --name ${registryContainerName} -p ${newPort}:5000 -v "${registryDataDir}:/var/lib/registry" registry:2`; result = await smartshellInstance.execSilent(retryCmd); } if (result.exitCode !== 0) { throw new Error(`Failed to start local registry: ${result.stderr || result.stdout}`); } // registry:2 starts near-instantly; brief wait for readiness await new Promise(resolve => setTimeout(resolve, 1000)); logger.log('info', `Started local registry at ${session.config.registryHost} (container: ${registryContainerName})`); if (isRootless) { logger.log('warn', `[rootless] Registry on port ${session.config.registryPort} — if buildx cannot reach localhost, try 127.0.0.1`); } } /** Stops and removes the session-specific local registry container. */ public static async stopLocalRegistry(session: TsDockerSession): Promise { await smartshellInstance.execSilent( `docker rm -f ${session.config.registryContainerName} 2>/dev/null || true` ); logger.log('info', `Stopped local registry (${session.config.registryContainerName})`); } /** Pushes a built image to the local registry for buildx consumption. */ public static async pushToLocalRegistry(session: TsDockerSession, dockerfile: Dockerfile): Promise { const registryTag = `${session.config.registryHost}/${dockerfile.buildTag}`; await smartshellInstance.execSilent(`docker tag ${dockerfile.buildTag} ${registryTag}`); const result = await smartshellInstance.execSilent(`docker push ${registryTag}`); if (result.exitCode !== 0) { throw new Error(`Failed to push to local registry: ${result.stderr || result.stdout}`); } dockerfile.localRegistryTag = registryTag; logger.log('info', `Pushed ${dockerfile.buildTag} to local registry as ${registryTag}`); } /** * Groups topologically sorted Dockerfiles into dependency levels. * Level 0 = no local dependencies; level N = depends on something in level N-1. * Images within the same level are independent and can build in parallel. */ public static computeLevels(sortedDockerfiles: Dockerfile[]): Dockerfile[][] { const levelMap = new Map(); for (const df of sortedDockerfiles) { if (!df.localBaseImageDependent || !df.localBaseDockerfile) { levelMap.set(df, 0); } else { const depLevel = levelMap.get(df.localBaseDockerfile) ?? 0; levelMap.set(df, depLevel + 1); } } const maxLevel = Math.max(...Array.from(levelMap.values()), 0); const levels: Dockerfile[][] = []; for (let l = 0; l <= maxLevel; l++) { levels.push(sortedDockerfiles.filter(df => levelMap.get(df) === l)); } return levels; } /** * Runs async tasks with bounded concurrency (worker-pool pattern). * Fast-fail: if any task throws, Promise.all rejects immediately. */ public static async runWithConcurrency( tasks: (() => Promise)[], concurrency: number, ): Promise { const results: T[] = new Array(tasks.length); let nextIndex = 0; async function worker(): Promise { while (true) { const idx = nextIndex++; if (idx >= tasks.length) break; results[idx] = await tasks[idx](); } } const workers = Array.from( { length: Math.min(concurrency, tasks.length) }, () => worker(), ); await Promise.all(workers); return results; } /** * Builds the corresponding real docker image for each Dockerfile class instance */ public static async buildDockerfiles( sortedArrayArg: Dockerfile[], session: TsDockerSession, options?: { platform?: string; timeout?: number; noCache?: boolean; verbose?: boolean; isRootless?: boolean; parallel?: boolean; parallelConcurrency?: number }, ): Promise { const total = sortedArrayArg.length; const overallStart = Date.now(); await Dockerfile.startLocalRegistry(session, options?.isRootless); try { if (options?.parallel) { // === PARALLEL MODE: build independent images concurrently within each level === const concurrency = options.parallelConcurrency ?? 4; const levels = Dockerfile.computeLevels(sortedArrayArg); logger.log('info', `Parallel build: ${levels.length} level(s), concurrency ${concurrency}`); for (let l = 0; l < levels.length; l++) { const level = levels[l]; logger.log('info', ` Level ${l} (${level.length}): ${level.map(df => df.cleanTag).join(', ')}`); } let built = 0; for (let l = 0; l < levels.length; l++) { const level = levels[l]; logger.log('info', `--- Level ${l}: building ${level.length} image(s) in parallel ---`); const tasks = level.map((df) => { const myIndex = ++built; return async () => { const progress = `(${myIndex}/${total})`; logger.log('info', `${progress} Building ${df.cleanTag}...`); const elapsed = await df.build(options); logger.log('ok', `${progress} Built ${df.cleanTag} in ${formatDuration(elapsed)}`); return df; }; }); await Dockerfile.runWithConcurrency(tasks, concurrency); // After the entire level completes, push all to local registry + tag for deps for (const df of level) { // Tag in host daemon for dependency resolution const dependentBaseImages = new Set(); for (const other of sortedArrayArg) { if (other.localBaseDockerfile === df && other.baseImage !== df.buildTag) { dependentBaseImages.add(other.baseImage); } } for (const fullTag of dependentBaseImages) { logger.log('info', `Tagging ${df.buildTag} as ${fullTag} for local dependency resolution`); await smartshellInstance.exec(`docker tag ${df.buildTag} ${fullTag}`); } // Push ALL images to local registry (skip if already pushed via buildx) if (!df.localRegistryTag) { await Dockerfile.pushToLocalRegistry(session, df); } } } } else { // === SEQUENTIAL MODE: build one at a time === for (let i = 0; i < total; i++) { const dockerfileArg = sortedArrayArg[i]; const progress = `(${i + 1}/${total})`; logger.log('info', `${progress} Building ${dockerfileArg.cleanTag}...`); const elapsed = await dockerfileArg.build(options); logger.log('ok', `${progress} Built ${dockerfileArg.cleanTag} in ${formatDuration(elapsed)}`); // Tag in host daemon for standard docker build compatibility const dependentBaseImages = new Set(); for (const other of sortedArrayArg) { if (other.localBaseDockerfile === dockerfileArg && other.baseImage !== dockerfileArg.buildTag) { dependentBaseImages.add(other.baseImage); } } for (const fullTag of dependentBaseImages) { logger.log('info', `Tagging ${dockerfileArg.buildTag} as ${fullTag} for local dependency resolution`); await smartshellInstance.exec(`docker tag ${dockerfileArg.buildTag} ${fullTag}`); } // Push ALL images to local registry (skip if already pushed via buildx) if (!dockerfileArg.localRegistryTag) { await Dockerfile.pushToLocalRegistry(session, dockerfileArg); } } } } finally { await Dockerfile.stopLocalRegistry(session); } logger.log('info', `Total build time: ${formatDuration(Date.now() - overallStart)}`); return sortedArrayArg; } /** * Tests all Dockerfiles by calling Dockerfile.test() */ public static async testDockerfiles(sortedArrayArg: Dockerfile[]): Promise { const total = sortedArrayArg.length; const overallStart = Date.now(); for (let i = 0; i < total; i++) { const dockerfileArg = sortedArrayArg[i]; const progress = `(${i + 1}/${total})`; logger.log('info', `${progress} Testing ${dockerfileArg.cleanTag}...`); const elapsed = await dockerfileArg.test(); logger.log('ok', `${progress} Tested ${dockerfileArg.cleanTag} in ${formatDuration(elapsed)}`); } logger.log('info', `Total test time: ${formatDuration(Date.now() - overallStart)}`); 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 ''; } }); } /** * Extracts the repo:version part from a full image reference, stripping any registry prefix. * Examples: * "registry.example.com/repo:version" -> "repo:version" * "repo:version" -> "repo:version" * "host.today/ht-docker-node:npmci" -> "ht-docker-node:npmci" */ private static extractRepoVersion(imageRef: string): string { const parts = imageRef.split('/'); if (parts.length === 1) { // No registry prefix: "repo:version" return imageRef; } // Check if first part looks like a registry (contains '.' or ':' or is 'localhost') const firstPart = parts[0]; const looksLikeRegistry = firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost'; if (looksLikeRegistry) { // Strip registry: "registry.example.com/repo:version" -> "repo:version" return parts.slice(1).join('/'); } // No registry prefix, could be "org/repo:version" return imageRef; } /** * 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 session?: TsDockerSession; 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; public localRegistryTag?: string; 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) { 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; } /** * Creates a line-by-line handler for Docker build output that logs * recognized layer/step lines in an emphasized format. */ private createBuildOutputHandler(verbose: boolean): { handleChunk: (chunk: Buffer | string) => void; } { let buffer = ''; const tag = this.cleanTag; const handleLine = (line: string) => { // In verbose mode, write raw output prefixed with tag for identification if (verbose) { process.stdout.write(`[${tag}] ${line}\n`); } // Buildx step: #N [platform step/total] INSTRUCTION const bxStep = line.match(/^#\d+ \[([^\]]+?)(\d+\/\d+)\] (.+)/); if (bxStep) { const prefix = bxStep[1].trim(); const step = bxStep[2]; const instruction = bxStep[3]; const platform = extractPlatform(prefix); const platStr = platform ? `${platform} ▸ ` : ''; logger.log('note', `[${tag}] ${platStr}[${step}] ${instruction}`); return; } // Buildx CACHED: #N CACHED const bxCached = line.match(/^#(\d+) CACHED/); if (bxCached) { logger.log('note', `[${tag}] CACHED`); return; } // Buildx DONE: #N DONE 12.3s const bxDone = line.match(/^#\d+ DONE (.+)/); if (bxDone) { const timing = bxDone[1]; if (!timing.startsWith('0.0')) { logger.log('note', `[${tag}] DONE ${timing}`); } return; } // Buildx export phase: #N exporting ... const bxExport = line.match(/^#\d+ exporting (.+)/); if (bxExport) { logger.log('note', `[${tag}] exporting ${bxExport[1]}`); return; } // Standard docker build: Step N/M : INSTRUCTION const stdStep = line.match(/^Step (\d+\/\d+) : (.+)/); if (stdStep) { logger.log('note', `[${tag}] Step ${stdStep[1]}: ${stdStep[2]}`); return; } }; return { handleChunk: (chunk: Buffer | string) => { buffer += chunk.toString(); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { const trimmed = line.replace(/\r$/, '').trim(); if (trimmed) handleLine(trimmed); } }, }; } /** * Builds the Dockerfile */ public async build(options?: { platform?: string; timeout?: number; noCache?: boolean; verbose?: boolean }): Promise { const startTime = Date.now(); const buildArgsString = await Dockerfile.getDockerBuildArgs(this.managerRef); const config = this.managerRef.config; const platformOverride = options?.platform; const timeout = options?.timeout; const noCacheFlag = options?.noCache ? ' --no-cache' : ''; const verbose = options?.verbose ?? false; let buildContextFlag = ''; if (this.localBaseImageDependent && this.localBaseDockerfile) { const fromImage = this.baseImage; if (this.localBaseDockerfile.localRegistryTag) { // BuildKit pulls from the local registry (reachable via host network) const registryTag = this.localBaseDockerfile.localRegistryTag; buildContextFlag = ` --build-context "${fromImage}=docker-image://${registryTag}"`; logger.log('info', `Using local registry build context: ${fromImage} -> docker-image://${registryTag}`); } } let buildCommand: string; if (platformOverride) { // Single platform override via buildx buildCommand = `docker buildx build --progress=plain --platform ${platformOverride}${noCacheFlag}${buildContextFlag} --load -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`; logger.log('info', `Build: buildx --platform ${platformOverride} --load`); } else if (config.platforms && config.platforms.length > 1) { // Multi-platform build using buildx — always push to local registry const platformString = config.platforms.join(','); const registryHost = this.session?.config.registryHost || 'localhost:5234'; const localTag = `${registryHost}/${this.buildTag}`; buildCommand = `docker buildx build --progress=plain --platform ${platformString}${noCacheFlag}${buildContextFlag} -t ${localTag} -f ${this.filePath} ${buildArgsString} --push .`; this.localRegistryTag = localTag; logger.log('info', `Build: buildx --platform ${platformString} --push to local registry`); } else { // Standard build const versionLabel = this.managerRef.projectInfo?.npm?.version || 'unknown'; buildCommand = `docker build --progress=plain --label="version=${versionLabel}"${noCacheFlag} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`; logger.log('info', 'Build: docker build (standard)'); } // Execute build with real-time layer logging const handler = this.createBuildOutputHandler(verbose); const streaming = await smartshellInstance.execStreamingSilent(buildCommand); // Intercept output for layer logging streaming.childProcess.stdout?.on('data', handler.handleChunk); streaming.childProcess.stderr?.on('data', handler.handleChunk); if (timeout) { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { streaming.childProcess.kill(); reject(new Error(`Build timed out after ${timeout}s for ${this.cleanTag}`)); }, timeout * 1000); }); const result = await Promise.race([streaming.finalPromise, timeoutPromise]); if (result.exitCode !== 0) { logger.log('error', `Build failed for ${this.cleanTag}`); throw new Error(`Build failed for ${this.cleanTag}`); } } else { const result = await streaming.finalPromise; if (result.exitCode !== 0) { logger.log('error', `Build failed for ${this.cleanTag}`); if (!verbose && result.stdout) { logger.log('error', `Build output:\n${result.stdout}`); } throw new Error(`Build failed for ${this.cleanTag}`); } } return Date.now() - startTime; } /** * Pushes the Dockerfile to a registry using OCI Distribution API copy * from the local registry to the remote registry. */ public async push(dockerRegistryArg: DockerRegistry, versionSuffix?: string): Promise { const destRepo = this.getDestRepo(dockerRegistryArg.registryUrl); const destTag = versionSuffix ? `${this.version}_${versionSuffix}` : this.version; const registryCopy = new RegistryCopy(); const registryHost = this.session?.config.registryHost || 'localhost:5234'; this.pushTag = `${dockerRegistryArg.registryUrl}/${destRepo}:${destTag}`; logger.log('info', `Pushing ${this.pushTag} via OCI copy from local registry...`); await registryCopy.copyImage( registryHost, this.repo, this.version, dockerRegistryArg.registryUrl, destRepo, destTag, { username: dockerRegistryArg.username, password: dockerRegistryArg.password }, ); logger.log('ok', `Pushed ${this.pushTag}`); } /** * Returns the destination repository for a given registry URL, * using registryRepoMap if configured, otherwise the default repo. */ private getDestRepo(registryUrl: string): string { const config = this.managerRef.config; return config.registryRepoMap?.[registryUrl] || this.repo; } /** * 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. * For multi-platform builds, uses the local registry tag so Docker can auto-pull. */ public async test(): Promise { const startTime = Date.now(); const testDir = this.managerRef.config.testDir || plugins.path.join(paths.cwd, 'test'); const testFile = plugins.path.join(testDir, 'test_' + this.version + '.sh'); // Use local registry tag for multi-platform images (not in daemon), otherwise buildTag const imageRef = this.localRegistryTag || this.buildTag; const sessionId = this.session?.config.sessionId || 'default'; const testContainerName = `tsdocker_test_${sessionId}`; const testImageName = `tsdocker_test_image_${sessionId}`; const testFileExists = fs.existsSync(testFile); if (testFileExists) { // Run tests in container await smartshellInstance.exec( `docker run --name ${testContainerName} --entrypoint="bash" ${imageRef} -c "mkdir /tsdocker_test"` ); await smartshellInstance.exec(`docker cp ${testFile} ${testContainerName}:/tsdocker_test/test.sh`); await smartshellInstance.exec(`docker commit ${testContainerName} ${testImageName}`); const testResult = await smartshellInstance.exec( `docker run --entrypoint="bash" ${testImageName} -x /tsdocker_test/test.sh` ); // Cleanup await smartshellInstance.exec(`docker rm ${testContainerName}`); await smartshellInstance.exec(`docker rmi --force ${testImageName}`); if (testResult.exitCode !== 0) { throw new Error(`Tests failed for ${this.cleanTag}`); } } else { logger.log('warn', `Skipping tests for ${this.cleanTag} — no test file at ${testFile}`); } return Date.now() - startTime; } /** * 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(); } }