846 lines
31 KiB
TypeScript
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();
|
|
}
|
|
}
|