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:
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user