Files
tsdocker/ts/classes.dockerfile.ts

846 lines
31 KiB
TypeScript

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<Dockerfile[]> {
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<Dockerfile[]> {
logger.log('info', 'Sorting Dockerfiles based on dependencies...');
// Map from cleanTag to Dockerfile instance for quick lookup
const tagToDockerfile = new Map<string, Dockerfile>();
dockerfiles.forEach((dockerfile) => {
tagToDockerfile.set(dockerfile.cleanTag, dockerfile);
});
// Build the dependency graph
const graph = new Map<Dockerfile, Dockerfile[]>();
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<Dockerfile>();
const tempMarked = new Set<Dockerfile>();
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<Dockerfile[]> {
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<void> {
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<void> {
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<void> {
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<Dockerfile, number>();
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<T>(
tasks: (() => Promise<T>)[],
concurrency: number,
): Promise<T[]> {
const results: T[] = new Array(tasks.length);
let nextIndex = 0;
async function worker(): Promise<void> {
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<Dockerfile[]> {
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<string>();
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<string>();
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<Dockerfile[]> {
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<string> {
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<number> {
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<never>((_, 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<void> {
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<void> {
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<number> {
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<string> {
const result = await smartshellInstance.exec(
'docker inspect --type=image --format="{{.Id}}" ' + this.buildTag
);
return result.stdout.trim();
}
}