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,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@git.zone/tsdocker',
|
||||
version: '1.15.1',
|
||||
version: '1.16.0',
|
||||
description: 'develop npm modules cross platform with docker'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as plugins from './tsdocker.plugins.js';
|
||||
import * as fs from 'fs';
|
||||
import { logger } from './tsdocker.logging.js';
|
||||
import type { IDockerContextInfo } from './interfaces/index.js';
|
||||
|
||||
@@ -38,19 +39,28 @@ export class DockerContext {
|
||||
isRootless = infoResult.stdout.includes('name=rootless');
|
||||
}
|
||||
|
||||
this.contextInfo = { name, endpoint, isRootless, dockerHost: process.env.DOCKER_HOST };
|
||||
// Detect topology
|
||||
let topology: 'socket-mount' | 'dind' | 'local' = 'local';
|
||||
if (process.env.DOCKER_HOST && process.env.DOCKER_HOST.startsWith('tcp://')) {
|
||||
topology = 'dind';
|
||||
} else if (fs.existsSync('/.dockerenv')) {
|
||||
topology = 'socket-mount';
|
||||
}
|
||||
|
||||
this.contextInfo = { name, endpoint, isRootless, dockerHost: process.env.DOCKER_HOST, topology };
|
||||
return this.contextInfo;
|
||||
}
|
||||
|
||||
/** Logs context info prominently. */
|
||||
public logContextInfo(): void {
|
||||
if (!this.contextInfo) return;
|
||||
const { name, endpoint, isRootless, dockerHost } = this.contextInfo;
|
||||
const { name, endpoint, isRootless, dockerHost, topology } = this.contextInfo;
|
||||
logger.log('info', '=== DOCKER CONTEXT ===');
|
||||
logger.log('info', `Context: ${name}`);
|
||||
logger.log('info', `Endpoint: ${endpoint}`);
|
||||
if (dockerHost) logger.log('info', `DOCKER_HOST: ${dockerHost}`);
|
||||
logger.log('info', `Rootless: ${isRootless ? 'yes' : 'no'}`);
|
||||
logger.log('info', `Topology: ${topology || 'local'}`);
|
||||
}
|
||||
|
||||
/** Emits rootless-specific warnings. */
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DockerRegistry } from './classes.dockerregistry.js';
|
||||
import { RegistryStorage } from './classes.registrystorage.js';
|
||||
import { TsDockerCache } from './classes.tsdockercache.js';
|
||||
import { DockerContext } from './classes.dockercontext.js';
|
||||
import { TsDockerSession } from './classes.tsdockersession.js';
|
||||
import type { ITsDockerConfig, IBuildCommandOptions } from './interfaces/index.js';
|
||||
|
||||
const smartshellInstance = new plugins.smartshell.Smartshell({
|
||||
@@ -20,6 +21,7 @@ export class TsDockerManager {
|
||||
public config: ITsDockerConfig;
|
||||
public projectInfo: any;
|
||||
public dockerContext: DockerContext;
|
||||
public session!: TsDockerSession;
|
||||
private dockerfiles: Dockerfile[] = [];
|
||||
|
||||
constructor(config: ITsDockerConfig) {
|
||||
@@ -77,6 +79,9 @@ export class TsDockerManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Create session identity (unique ports, names for CI concurrency)
|
||||
this.session = await TsDockerSession.create();
|
||||
|
||||
logger.log('info', `Prepared TsDockerManager with ${this.registryStorage.getAllRegistries().length} registries`);
|
||||
}
|
||||
|
||||
@@ -98,6 +103,10 @@ export class TsDockerManager {
|
||||
this.dockerfiles = await Dockerfile.readDockerfiles(this);
|
||||
this.dockerfiles = await Dockerfile.sortDockerfiles(this.dockerfiles);
|
||||
this.dockerfiles = await Dockerfile.mapDockerfiles(this.dockerfiles);
|
||||
// Inject session into each Dockerfile
|
||||
for (const df of this.dockerfiles) {
|
||||
df.session = this.session;
|
||||
}
|
||||
return this.dockerfiles;
|
||||
}
|
||||
|
||||
@@ -187,7 +196,7 @@ export class TsDockerManager {
|
||||
|
||||
const total = toBuild.length;
|
||||
const overallStart = Date.now();
|
||||
await Dockerfile.startLocalRegistry(this.dockerContext.contextInfo?.isRootless);
|
||||
await Dockerfile.startLocalRegistry(this.session, this.dockerContext.contextInfo?.isRootless);
|
||||
|
||||
try {
|
||||
if (options?.parallel) {
|
||||
@@ -240,7 +249,7 @@ export class TsDockerManager {
|
||||
}
|
||||
// Push ALL images to local registry (skip if already pushed via buildx)
|
||||
if (!df.localRegistryTag) {
|
||||
await Dockerfile.pushToLocalRegistry(df);
|
||||
await Dockerfile.pushToLocalRegistry(this.session, df);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,19 +289,19 @@ export class TsDockerManager {
|
||||
|
||||
// Push ALL images to local registry (skip if already pushed via buildx)
|
||||
if (!dockerfileArg.localRegistryTag) {
|
||||
await Dockerfile.pushToLocalRegistry(dockerfileArg);
|
||||
await Dockerfile.pushToLocalRegistry(this.session, dockerfileArg);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await Dockerfile.stopLocalRegistry();
|
||||
await Dockerfile.stopLocalRegistry(this.session);
|
||||
}
|
||||
|
||||
logger.log('info', `Total build time: ${formatDuration(Date.now() - overallStart)}`);
|
||||
cache.save();
|
||||
} else {
|
||||
// === STANDARD MODE: build all via static helper ===
|
||||
await Dockerfile.buildDockerfiles(toBuild, {
|
||||
await Dockerfile.buildDockerfiles(toBuild, this.session, {
|
||||
platform: options?.platform,
|
||||
timeout: options?.timeout,
|
||||
noCache: options?.noCache,
|
||||
@@ -329,7 +338,7 @@ export class TsDockerManager {
|
||||
* Ensures Docker buildx is set up for multi-architecture builds
|
||||
*/
|
||||
private async ensureBuildx(): Promise<void> {
|
||||
const builderName = this.dockerContext.getBuilderName();
|
||||
const builderName = this.dockerContext.getBuilderName() + (this.session?.config.builderSuffix || '');
|
||||
const platforms = this.config.platforms?.join(', ') || 'default';
|
||||
logger.log('info', `Setting up Docker buildx [${platforms}]...`);
|
||||
logger.log('info', `Builder: ${builderName}`);
|
||||
@@ -394,7 +403,7 @@ export class TsDockerManager {
|
||||
}
|
||||
|
||||
// Start local registry (reads from persistent .nogit/docker-registry/)
|
||||
await Dockerfile.startLocalRegistry(this.dockerContext.contextInfo?.isRootless);
|
||||
await Dockerfile.startLocalRegistry(this.session, this.dockerContext.contextInfo?.isRootless);
|
||||
try {
|
||||
// Push each Dockerfile to each registry via OCI copy
|
||||
for (const dockerfile of this.dockerfiles) {
|
||||
@@ -403,7 +412,7 @@ export class TsDockerManager {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await Dockerfile.stopLocalRegistry();
|
||||
await Dockerfile.stopLocalRegistry(this.session);
|
||||
}
|
||||
|
||||
logger.log('success', 'All images pushed successfully');
|
||||
@@ -446,11 +455,11 @@ export class TsDockerManager {
|
||||
logger.log('info', '');
|
||||
logger.log('info', '=== TEST PHASE ===');
|
||||
|
||||
await Dockerfile.startLocalRegistry(this.dockerContext.contextInfo?.isRootless);
|
||||
await Dockerfile.startLocalRegistry(this.session, this.dockerContext.contextInfo?.isRootless);
|
||||
try {
|
||||
await Dockerfile.testDockerfiles(this.dockerfiles);
|
||||
} finally {
|
||||
await Dockerfile.stopLocalRegistry();
|
||||
await Dockerfile.stopLocalRegistry(this.session);
|
||||
}
|
||||
|
||||
logger.log('success', 'All tests completed');
|
||||
@@ -490,4 +499,16 @@ export class TsDockerManager {
|
||||
public getDockerfiles(): Dockerfile[] {
|
||||
return this.dockerfiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up session-specific resources.
|
||||
* In CI, removes the session-specific buildx builder to avoid accumulation.
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
if (this.session?.config.isCI && this.session.config.builderSuffix) {
|
||||
const builderName = this.dockerContext.getBuilderName() + this.session.config.builderSuffix;
|
||||
logger.log('info', `CI cleanup: removing buildx builder ${builderName}`);
|
||||
await smartshellInstance.execSilent(`docker buildx rm ${builderName} 2>/dev/null || true`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
107
ts/classes.tsdockersession.ts
Normal file
107
ts/classes.tsdockersession.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as net from 'net';
|
||||
import { logger } from './tsdocker.logging.js';
|
||||
|
||||
export interface ISessionConfig {
|
||||
sessionId: string;
|
||||
registryPort: number;
|
||||
registryHost: string;
|
||||
registryContainerName: string;
|
||||
isCI: boolean;
|
||||
ciSystem: string | null;
|
||||
builderSuffix: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-invocation session identity for tsdocker.
|
||||
* Generates unique ports, container names, and builder names so that
|
||||
* concurrent CI jobs on the same Docker host don't collide.
|
||||
*
|
||||
* In local (non-CI) dev the builder suffix is empty, preserving the
|
||||
* persistent builder behavior.
|
||||
*/
|
||||
export class TsDockerSession {
|
||||
public config: ISessionConfig;
|
||||
|
||||
private constructor(config: ISessionConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new session. Allocates a dynamic port unless overridden
|
||||
* via `TSDOCKER_REGISTRY_PORT`.
|
||||
*/
|
||||
public static async create(): Promise<TsDockerSession> {
|
||||
const sessionId =
|
||||
process.env.TSDOCKER_SESSION_ID || crypto.randomBytes(4).toString('hex');
|
||||
|
||||
const registryPort = await TsDockerSession.allocatePort();
|
||||
const registryHost = `localhost:${registryPort}`;
|
||||
const registryContainerName = `tsdocker-registry-${sessionId}`;
|
||||
|
||||
const { isCI, ciSystem } = TsDockerSession.detectCI();
|
||||
const builderSuffix = isCI ? `-${sessionId}` : '';
|
||||
|
||||
const config: ISessionConfig = {
|
||||
sessionId,
|
||||
registryPort,
|
||||
registryHost,
|
||||
registryContainerName,
|
||||
isCI,
|
||||
ciSystem,
|
||||
builderSuffix,
|
||||
};
|
||||
|
||||
const session = new TsDockerSession(config);
|
||||
session.logInfo();
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocates a free TCP port. Respects `TSDOCKER_REGISTRY_PORT` override.
|
||||
*/
|
||||
public static async allocatePort(): Promise<number> {
|
||||
const envPort = process.env.TSDOCKER_REGISTRY_PORT;
|
||||
if (envPort) {
|
||||
const parsed = parseInt(envPort, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const addr = srv.address() as net.AddressInfo;
|
||||
const port = addr.port;
|
||||
srv.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
srv.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether we're running inside a CI system.
|
||||
*/
|
||||
private static detectCI(): { isCI: boolean; ciSystem: string | null } {
|
||||
if (process.env.GITEA_ACTIONS) return { isCI: true, ciSystem: 'gitea-actions' };
|
||||
if (process.env.GITHUB_ACTIONS) return { isCI: true, ciSystem: 'github-actions' };
|
||||
if (process.env.GITLAB_CI) return { isCI: true, ciSystem: 'gitlab-ci' };
|
||||
if (process.env.CI) return { isCI: true, ciSystem: 'generic' };
|
||||
return { isCI: false, ciSystem: null };
|
||||
}
|
||||
|
||||
private logInfo(): void {
|
||||
const c = this.config;
|
||||
logger.log('info', '=== TSDOCKER SESSION ===');
|
||||
logger.log('info', `Session ID: ${c.sessionId}`);
|
||||
logger.log('info', `Registry: ${c.registryHost} (container: ${c.registryContainerName})`);
|
||||
if (c.isCI) {
|
||||
logger.log('info', `CI detected: ${c.ciSystem}`);
|
||||
logger.log('info', `Builder suffix: ${c.builderSuffix}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,4 +101,5 @@ export interface IDockerContextInfo {
|
||||
endpoint: string; // 'unix:///var/run/docker.sock'
|
||||
isRootless: boolean;
|
||||
dockerHost?: string; // value of DOCKER_HOST env var, if set
|
||||
topology?: 'socket-mount' | 'dind' | 'local';
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export let run = () => {
|
||||
}
|
||||
|
||||
await manager.build(buildOptions);
|
||||
await manager.cleanup();
|
||||
logger.log('success', 'Build completed successfully');
|
||||
} catch (err) {
|
||||
logger.log('error', `Build failed: ${(err as Error).message}`);
|
||||
@@ -117,6 +118,7 @@ export let run = () => {
|
||||
const registries = registryArg ? [registryArg] : undefined;
|
||||
|
||||
await manager.push(registries);
|
||||
await manager.cleanup();
|
||||
logger.log('success', 'Push completed successfully');
|
||||
} catch (err) {
|
||||
logger.log('error', `Push failed: ${(err as Error).message}`);
|
||||
@@ -180,6 +182,7 @@ export let run = () => {
|
||||
|
||||
// Run tests
|
||||
await manager.test();
|
||||
await manager.cleanup();
|
||||
logger.log('success', 'Tests completed successfully');
|
||||
} catch (err) {
|
||||
logger.log('error', `Tests failed: ${(err as Error).message}`);
|
||||
|
||||
Reference in New Issue
Block a user