feat(core): Introduce per-invocation TsDockerSession and session-aware local registry and build orchestration; stream and parse buildx output for improved logging and visibility; detect Docker topology and add CI-safe cleanup; update README with multi-arch, parallel-build, caching, and local registry usage and new CLI flags.

This commit is contained in:
2026-02-07 10:30:52 +00:00
parent 63078139ec
commit 101c4286c1
9 changed files with 500 additions and 167 deletions

View File

@@ -3,6 +3,7 @@ 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';
@@ -11,9 +12,14 @@ const smartshellInstance = new plugins.smartshell.Smartshell({
executor: 'bash',
});
const LOCAL_REGISTRY_PORT = 5234;
const LOCAL_REGISTRY_HOST = `localhost:${LOCAL_REGISTRY_PORT}`;
const LOCAL_REGISTRY_CONTAINER = 'tsdocker-local-registry';
/**
* 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
@@ -148,40 +154,55 @@ export class Dockerfile {
return true;
}
/** Starts a persistent registry:2 container on port 5234 with volume storage. */
public static async startLocalRegistry(isRootless?: boolean): Promise<void> {
// Ensure persistent storage directory exists
const registryDataDir = plugins.path.join(paths.cwd, '.nogit', 'docker-registry');
/** 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 ${LOCAL_REGISTRY_CONTAINER} 2>/dev/null || true`
);
const result = await smartshellInstance.execSilent(
`docker run -d --name ${LOCAL_REGISTRY_CONTAINER} -p ${LOCAL_REGISTRY_PORT}:5000 -v "${registryDataDir}:/var/lib/registry" registry:2`
`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 ${LOCAL_REGISTRY_HOST} (persistent storage at .nogit/docker-registry/)`);
logger.log('info', `Started local registry at ${session.config.registryHost} (container: ${registryContainerName})`);
if (isRootless) {
logger.log('warn', `[rootless] Registry on port ${LOCAL_REGISTRY_PORT} — if buildx cannot reach localhost:${LOCAL_REGISTRY_PORT}, try 127.0.0.1:${LOCAL_REGISTRY_PORT}`);
logger.log('warn', `[rootless] Registry on port ${session.config.registryPort} — if buildx cannot reach localhost, try 127.0.0.1`);
}
}
/** Stops and removes the temporary local registry container. */
public static async stopLocalRegistry(): Promise<void> {
/** Stops and removes the session-specific local registry container. */
public static async stopLocalRegistry(session: TsDockerSession): Promise<void> {
await smartshellInstance.execSilent(
`docker rm -f ${LOCAL_REGISTRY_CONTAINER} 2>/dev/null || true`
`docker rm -f ${session.config.registryContainerName} 2>/dev/null || true`
);
logger.log('info', 'Stopped local registry');
logger.log('info', `Stopped local registry (${session.config.registryContainerName})`);
}
/** Pushes a built image to the local registry for buildx consumption. */
public static async pushToLocalRegistry(dockerfile: Dockerfile): Promise<void> {
const registryTag = `${LOCAL_REGISTRY_HOST}/${dockerfile.buildTag}`;
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) {
@@ -244,12 +265,13 @@ export class Dockerfile {
*/
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(options?.isRootless);
await Dockerfile.startLocalRegistry(session, options?.isRootless);
try {
if (options?.parallel) {
@@ -296,7 +318,7 @@ export class Dockerfile {
}
// Push ALL images to local registry (skip if already pushed via buildx)
if (!df.localRegistryTag) {
await Dockerfile.pushToLocalRegistry(df);
await Dockerfile.pushToLocalRegistry(session, df);
}
}
}
@@ -324,12 +346,12 @@ export class Dockerfile {
// Push ALL images to local registry (skip if already pushed via buildx)
if (!dockerfileArg.localRegistryTag) {
await Dockerfile.pushToLocalRegistry(dockerfileArg);
await Dockerfile.pushToLocalRegistry(session, dockerfileArg);
}
}
}
} finally {
await Dockerfile.stopLocalRegistry();
await Dockerfile.stopLocalRegistry(session);
}
logger.log('info', `Total build time: ${formatDuration(Date.now() - overallStart)}`);
@@ -520,6 +542,7 @@ export class Dockerfile {
// INSTANCE PROPERTIES
public managerRef: TsDockerManager;
public session?: TsDockerSession;
public filePath!: string;
public repo: string;
public version: string;
@@ -563,6 +586,79 @@ export class Dockerfile {
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
*/
@@ -590,27 +686,32 @@ export class Dockerfile {
if (platformOverride) {
// Single platform override via buildx
buildCommand = `docker buildx build --platform ${platformOverride}${noCacheFlag}${buildContextFlag} --load -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
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 localTag = `${LOCAL_REGISTRY_HOST}/${this.buildTag}`;
buildCommand = `docker buildx build --platform ${platformString}${noCacheFlag}${buildContextFlag} -t ${localTag} -f ${this.filePath} ${buildArgsString} --push .`;
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 --label="version=${versionLabel}"${noCacheFlag} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
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) {
// Use streaming execution with timeout
const streaming = verbose
? await smartshellInstance.execStreaming(buildCommand)
: await smartshellInstance.execStreamingSilent(buildCommand);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
streaming.childProcess.kill();
@@ -623,9 +724,7 @@ export class Dockerfile {
throw new Error(`Build failed for ${this.cleanTag}`);
}
} else {
const result = verbose
? await smartshellInstance.exec(buildCommand)
: await smartshellInstance.execSilent(buildCommand);
const result = await streaming.finalPromise;
if (result.exitCode !== 0) {
logger.log('error', `Build failed for ${this.cleanTag}`);
if (!verbose && result.stdout) {
@@ -646,12 +745,13 @@ export class Dockerfile {
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(
LOCAL_REGISTRY_HOST,
registryHost,
this.repo,
this.version,
dockerRegistryArg.registryUrl,
@@ -701,23 +801,27 @@ export class Dockerfile {
// 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 tsdocker_test_container --entrypoint="bash" ${imageRef} -c "mkdir /tsdocker_test"`
`docker run --name ${testContainerName} --entrypoint="bash" ${imageRef} -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`);
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" tsdocker_test_image -x /tsdocker_test/test.sh`
`docker run --entrypoint="bash" ${testImageName} -x /tsdocker_test/test.sh`
);
// Cleanup
await smartshellInstance.exec(`docker rm tsdocker_test_container`);
await smartshellInstance.exec(`docker rmi --force tsdocker_test_image`);
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}`);