Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17de78aed3 | |||
| eddb8cd156 | |||
| cfc7798d49 | |||
| 37dfde005e | |||
| d1785aab86 | |||
| 31fb4aea3c | |||
| 907048fa87 | |||
| 02b267ee10 |
36
changelog.md
36
changelog.md
@@ -1,5 +1,41 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-06 - 1.12.0 - feat(docker)
|
||||||
|
add detailed logging for buildx, build commands, local registry, and local dependency info
|
||||||
|
|
||||||
|
- Log startup of local registry including a note about buildx dependency bridging
|
||||||
|
- Log constructed build commands and indicate whether buildx or standard docker build is used (including platforms and --push/--load distinctions)
|
||||||
|
- Emit build mode summary at start of build phase and report local base-image dependency mappings
|
||||||
|
- Report when --no-cache is enabled and surface buildx setup readiness with configured platforms
|
||||||
|
- Non-functional change: purely adds informational logging to improve observability during builds
|
||||||
|
|
||||||
|
## 2026-02-06 - 1.11.0 - feat(docker)
|
||||||
|
start temporary local registry for buildx dependency resolution and ensure buildx builder uses host network
|
||||||
|
|
||||||
|
- Introduce a temporary local registry (localhost:5234) with start/stop helpers and push support to expose local images for buildx
|
||||||
|
- Add Dockerfile.needsLocalRegistry to decide when a local registry is required (local base dependencies + multi-platform or platform option)
|
||||||
|
- Push built images to the local registry and set localRegistryTag on Dockerfile instances for BuildKit build-context usage
|
||||||
|
- Tag built images in the host daemon for dependent Dockerfiles to resolve local FROM references
|
||||||
|
- Integrate registry lifecycle into Dockerfile.buildDockerfiles and TsDockerManager build flows (start before builds, stop after)
|
||||||
|
- Ensure buildx builder is created with --driver-opt network=host and recreate existing builder if it lacks host network to allow registry access from build containers
|
||||||
|
|
||||||
|
## 2026-02-06 - 1.10.0 - feat(classes.dockerfile)
|
||||||
|
support using a local base image as a build context in buildx commands
|
||||||
|
|
||||||
|
- Adds --build-context flag mapping base image to docker-image://<localTag> when localBaseImageDependent && localBaseDockerfile are set
|
||||||
|
- Appends the build context flag to both single-platform and multi-platform docker buildx commands
|
||||||
|
- Logs an info message indicating the local build context mapping
|
||||||
|
|
||||||
|
## 2026-02-06 - 1.9.0 - feat(build)
|
||||||
|
add verbose build output, progress logging, and timing for builds/tests
|
||||||
|
|
||||||
|
- Add 'verbose' option to build/test flows (interfaces, CLI, and method signatures) to allow streaming raw docker build output or run silently
|
||||||
|
- Log per-item progress for build and test phases (e.g. (1/N) Building/Testing <tag>) and report individual durations
|
||||||
|
- Return elapsed time from Dockerfile.build() and Dockerfile.test() and aggregate total build/test times in manager
|
||||||
|
- Introduce formatDuration(ms) helper in logging module to format timings
|
||||||
|
- Switch from console.log to structured logger calls across cache, manager, dockerfile and push paths
|
||||||
|
- Use silent exec variants when verbose is false and stream exec when verbose is true
|
||||||
|
|
||||||
## 2026-02-06 - 1.8.0 - feat(build)
|
## 2026-02-06 - 1.8.0 - feat(build)
|
||||||
add optional content-hash based build cache to skip rebuilding unchanged Dockerfiles
|
add optional content-hash based build cache to skip rebuilding unchanged Dockerfiles
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@git.zone/tsdocker",
|
"name": "@git.zone/tsdocker",
|
||||||
"version": "1.8.0",
|
"version": "1.12.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "develop npm modules cross platform with docker",
|
"description": "develop npm modules cross platform with docker",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@git.zone/tsdocker',
|
name: '@git.zone/tsdocker',
|
||||||
version: '1.8.0',
|
version: '1.12.0',
|
||||||
description: 'develop npm modules cross platform with docker'
|
description: 'develop npm modules cross platform with docker'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from './tsdocker.plugins.js';
|
import * as plugins from './tsdocker.plugins.js';
|
||||||
import * as paths from './tsdocker.paths.js';
|
import * as paths from './tsdocker.paths.js';
|
||||||
import { logger } from './tsdocker.logging.js';
|
import { logger, formatDuration } from './tsdocker.logging.js';
|
||||||
import { DockerRegistry } from './classes.dockerregistry.js';
|
import { DockerRegistry } from './classes.dockerregistry.js';
|
||||||
import type { IDockerfileOptions, ITsDockerConfig, IBuildCommandOptions } from './interfaces/index.js';
|
import type { IDockerfileOptions, ITsDockerConfig, IBuildCommandOptions } from './interfaces/index.js';
|
||||||
import type { TsDockerManager } from './classes.tsdockermanager.js';
|
import type { TsDockerManager } from './classes.tsdockermanager.js';
|
||||||
@@ -10,6 +10,10 @@ const smartshellInstance = new plugins.smartshell.Smartshell({
|
|||||||
executor: 'bash',
|
executor: 'bash',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const LOCAL_REGISTRY_PORT = 5234;
|
||||||
|
const LOCAL_REGISTRY_HOST = `localhost:${LOCAL_REGISTRY_PORT}`;
|
||||||
|
const LOCAL_REGISTRY_CONTAINER = 'tsdocker-local-registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class Dockerfile represents a Dockerfile on disk
|
* Class Dockerfile represents a Dockerfile on disk
|
||||||
*/
|
*/
|
||||||
@@ -26,8 +30,10 @@ export class Dockerfile {
|
|||||||
.map(entry => plugins.path.join(paths.cwd, entry.name));
|
.map(entry => plugins.path.join(paths.cwd, entry.name));
|
||||||
|
|
||||||
const readDockerfilesArray: Dockerfile[] = [];
|
const readDockerfilesArray: Dockerfile[] = [];
|
||||||
logger.log('info', `found ${fileTree.length} Dockerfiles:`);
|
logger.log('info', `found ${fileTree.length} Dockerfile(s):`);
|
||||||
console.log(fileTree);
|
for (const filePath of fileTree) {
|
||||||
|
logger.log('info', ` ${plugins.path.basename(filePath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
for (const dockerfilePath of fileTree) {
|
for (const dockerfilePath of fileTree) {
|
||||||
const myDockerfile = new Dockerfile(managerRef, {
|
const myDockerfile = new Dockerfile(managerRef, {
|
||||||
@@ -133,29 +139,101 @@ export class Dockerfile {
|
|||||||
return sortedDockerfileArray;
|
return sortedDockerfileArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Determines if a local registry is needed for buildx dependency resolution. */
|
||||||
|
public static needsLocalRegistry(
|
||||||
|
dockerfiles: Dockerfile[],
|
||||||
|
options?: { platform?: string },
|
||||||
|
): boolean {
|
||||||
|
const hasLocalDeps = dockerfiles.some(df => df.localBaseImageDependent);
|
||||||
|
if (!hasLocalDeps) return false;
|
||||||
|
const config = dockerfiles[0]?.managerRef?.config;
|
||||||
|
return !!options?.platform || !!(config?.platforms && config.platforms.length > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts a temporary registry:2 container on port 5234. */
|
||||||
|
public static async startLocalRegistry(): Promise<void> {
|
||||||
|
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 registry:2`
|
||||||
|
);
|
||||||
|
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} (buildx dependency bridge)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops and removes the temporary local registry container. */
|
||||||
|
public static async stopLocalRegistry(): Promise<void> {
|
||||||
|
await smartshellInstance.execSilent(
|
||||||
|
`docker rm -f ${LOCAL_REGISTRY_CONTAINER} 2>/dev/null || true`
|
||||||
|
);
|
||||||
|
logger.log('info', 'Stopped local registry');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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}`;
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the corresponding real docker image for each Dockerfile class instance
|
* Builds the corresponding real docker image for each Dockerfile class instance
|
||||||
*/
|
*/
|
||||||
public static async buildDockerfiles(
|
public static async buildDockerfiles(
|
||||||
sortedArrayArg: Dockerfile[],
|
sortedArrayArg: Dockerfile[],
|
||||||
options?: { platform?: string; timeout?: number; noCache?: boolean },
|
options?: { platform?: string; timeout?: number; noCache?: boolean; verbose?: boolean },
|
||||||
): Promise<Dockerfile[]> {
|
): Promise<Dockerfile[]> {
|
||||||
for (const dockerfileArg of sortedArrayArg) {
|
const total = sortedArrayArg.length;
|
||||||
await dockerfileArg.build(options);
|
const overallStart = Date.now();
|
||||||
|
const useRegistry = Dockerfile.needsLocalRegistry(sortedArrayArg, options);
|
||||||
|
|
||||||
// Tag the built image with the full base image references used by dependent Dockerfiles,
|
if (useRegistry) {
|
||||||
// so their FROM lines resolve to the locally-built image instead of pulling from a registry.
|
await Dockerfile.startLocalRegistry();
|
||||||
const dependentBaseImages = new Set<string>();
|
}
|
||||||
for (const other of sortedArrayArg) {
|
|
||||||
if (other.localBaseDockerfile === dockerfileArg && other.baseImage !== dockerfileArg.buildTag) {
|
try {
|
||||||
dependentBaseImages.add(other.baseImage);
|
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 to local registry for buildx dependency resolution
|
||||||
|
if (useRegistry && sortedArrayArg.some(other => other.localBaseDockerfile === dockerfileArg)) {
|
||||||
|
await Dockerfile.pushToLocalRegistry(dockerfileArg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const fullTag of dependentBaseImages) {
|
} finally {
|
||||||
logger.log('info', `Tagging ${dockerfileArg.buildTag} as ${fullTag} for local dependency resolution`);
|
if (useRegistry) {
|
||||||
await smartshellInstance.exec(`docker tag ${dockerfileArg.buildTag} ${fullTag}`);
|
await Dockerfile.stopLocalRegistry();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.log('info', `Total build time: ${formatDuration(Date.now() - overallStart)}`);
|
||||||
return sortedArrayArg;
|
return sortedArrayArg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,9 +241,19 @@ export class Dockerfile {
|
|||||||
* Tests all Dockerfiles by calling Dockerfile.test()
|
* Tests all Dockerfiles by calling Dockerfile.test()
|
||||||
*/
|
*/
|
||||||
public static async testDockerfiles(sortedArrayArg: Dockerfile[]): Promise<Dockerfile[]> {
|
public static async testDockerfiles(sortedArrayArg: Dockerfile[]): Promise<Dockerfile[]> {
|
||||||
for (const dockerfileArg of sortedArrayArg) {
|
const total = sortedArrayArg.length;
|
||||||
await dockerfileArg.test();
|
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;
|
return sortedArrayArg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +432,7 @@ export class Dockerfile {
|
|||||||
public baseImage: string;
|
public baseImage: string;
|
||||||
public localBaseImageDependent: boolean;
|
public localBaseImageDependent: boolean;
|
||||||
public localBaseDockerfile!: Dockerfile;
|
public localBaseDockerfile!: Dockerfile;
|
||||||
|
public localRegistryTag?: string;
|
||||||
|
|
||||||
constructor(managerRefArg: TsDockerManager, options: IDockerfileOptions) {
|
constructor(managerRefArg: TsDockerManager, options: IDockerfileOptions) {
|
||||||
this.managerRef = managerRefArg;
|
this.managerRef = managerRefArg;
|
||||||
@@ -378,38 +467,56 @@ export class Dockerfile {
|
|||||||
/**
|
/**
|
||||||
* Builds the Dockerfile
|
* Builds the Dockerfile
|
||||||
*/
|
*/
|
||||||
public async build(options?: { platform?: string; timeout?: number; noCache?: boolean }): Promise<void> {
|
public async build(options?: { platform?: string; timeout?: number; noCache?: boolean; verbose?: boolean }): Promise<number> {
|
||||||
logger.log('info', 'now building Dockerfile for ' + this.cleanTag);
|
const startTime = Date.now();
|
||||||
const buildArgsString = await Dockerfile.getDockerBuildArgs(this.managerRef);
|
const buildArgsString = await Dockerfile.getDockerBuildArgs(this.managerRef);
|
||||||
const config = this.managerRef.config;
|
const config = this.managerRef.config;
|
||||||
const platformOverride = options?.platform;
|
const platformOverride = options?.platform;
|
||||||
const timeout = options?.timeout;
|
const timeout = options?.timeout;
|
||||||
const noCacheFlag = options?.noCache ? ' --no-cache' : '';
|
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;
|
let buildCommand: string;
|
||||||
|
|
||||||
if (platformOverride) {
|
if (platformOverride) {
|
||||||
// Single platform override via buildx
|
// Single platform override via buildx
|
||||||
buildCommand = `docker buildx build --platform ${platformOverride}${noCacheFlag} --load -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
|
buildCommand = `docker buildx build --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) {
|
} else if (config.platforms && config.platforms.length > 1) {
|
||||||
// Multi-platform build using buildx
|
// Multi-platform build using buildx
|
||||||
const platformString = config.platforms.join(',');
|
const platformString = config.platforms.join(',');
|
||||||
buildCommand = `docker buildx build --platform ${platformString}${noCacheFlag} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
|
buildCommand = `docker buildx build --platform ${platformString}${noCacheFlag}${buildContextFlag} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
|
||||||
|
|
||||||
if (config.push) {
|
if (config.push) {
|
||||||
buildCommand += ' --push';
|
buildCommand += ' --push';
|
||||||
|
logger.log('info', `Build: buildx --platform ${platformString} --push`);
|
||||||
} else {
|
} else {
|
||||||
buildCommand += ' --load';
|
buildCommand += ' --load';
|
||||||
|
logger.log('info', `Build: buildx --platform ${platformString} --load`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Standard build
|
// Standard build
|
||||||
const versionLabel = this.managerRef.projectInfo?.npm?.version || 'unknown';
|
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 --label="version=${versionLabel}"${noCacheFlag} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
|
||||||
|
logger.log('info', 'Build: docker build (standard)');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timeout) {
|
if (timeout) {
|
||||||
// Use streaming execution with timeout
|
// Use streaming execution with timeout
|
||||||
const streaming = await smartshellInstance.execStreaming(buildCommand);
|
const streaming = verbose
|
||||||
|
? await smartshellInstance.execStreaming(buildCommand)
|
||||||
|
: await smartshellInstance.execStreamingSilent(buildCommand);
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
streaming.childProcess.kill();
|
streaming.childProcess.kill();
|
||||||
@@ -422,15 +529,19 @@ export class Dockerfile {
|
|||||||
throw new Error(`Build failed for ${this.cleanTag}`);
|
throw new Error(`Build failed for ${this.cleanTag}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const result = await smartshellInstance.exec(buildCommand);
|
const result = verbose
|
||||||
|
? await smartshellInstance.exec(buildCommand)
|
||||||
|
: await smartshellInstance.execSilent(buildCommand);
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
logger.log('error', `Build failed for ${this.cleanTag}`);
|
logger.log('error', `Build failed for ${this.cleanTag}`);
|
||||||
console.log(result.stdout);
|
if (!verbose && result.stdout) {
|
||||||
|
logger.log('error', `Build output:\n${result.stdout}`);
|
||||||
|
}
|
||||||
throw new Error(`Build failed for ${this.cleanTag}`);
|
throw new Error(`Build failed for ${this.cleanTag}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('ok', `Built ${this.cleanTag}`);
|
return Date.now() - startTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -460,7 +571,7 @@ export class Dockerfile {
|
|||||||
|
|
||||||
if (inspectResult.exitCode === 0 && inspectResult.stdout.includes('@')) {
|
if (inspectResult.exitCode === 0 && inspectResult.stdout.includes('@')) {
|
||||||
const imageDigest = inspectResult.stdout.split('@')[1]?.trim();
|
const imageDigest = inspectResult.stdout.split('@')[1]?.trim();
|
||||||
console.log(`The image ${this.pushTag} has digest ${imageDigest}`);
|
logger.log('info', `The image ${this.pushTag} has digest ${imageDigest}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('ok', `Pushed ${this.pushTag}`);
|
logger.log('ok', `Pushed ${this.pushTag}`);
|
||||||
@@ -487,15 +598,14 @@ export class Dockerfile {
|
|||||||
/**
|
/**
|
||||||
* Tests the Dockerfile by running a test script if it exists
|
* Tests the Dockerfile by running a test script if it exists
|
||||||
*/
|
*/
|
||||||
public async test(): Promise<void> {
|
public async test(): Promise<number> {
|
||||||
|
const startTime = Date.now();
|
||||||
const testDir = this.managerRef.config.testDir || plugins.path.join(paths.cwd, 'test');
|
const testDir = this.managerRef.config.testDir || plugins.path.join(paths.cwd, 'test');
|
||||||
const testFile = plugins.path.join(testDir, 'test_' + this.version + '.sh');
|
const testFile = plugins.path.join(testDir, 'test_' + this.version + '.sh');
|
||||||
|
|
||||||
const testFileExists = fs.existsSync(testFile);
|
const testFileExists = fs.existsSync(testFile);
|
||||||
|
|
||||||
if (testFileExists) {
|
if (testFileExists) {
|
||||||
logger.log('info', `Running tests for ${this.cleanTag}`);
|
|
||||||
|
|
||||||
// Run tests in container
|
// Run tests in container
|
||||||
await smartshellInstance.exec(
|
await smartshellInstance.exec(
|
||||||
`docker run --name tsdocker_test_container --entrypoint="bash" ${this.buildTag} -c "mkdir /tsdocker_test"`
|
`docker run --name tsdocker_test_container --entrypoint="bash" ${this.buildTag} -c "mkdir /tsdocker_test"`
|
||||||
@@ -514,11 +624,11 @@ export class Dockerfile {
|
|||||||
if (testResult.exitCode !== 0) {
|
if (testResult.exitCode !== 0) {
|
||||||
throw new Error(`Tests failed for ${this.cleanTag}`);
|
throw new Error(`Tests failed for ${this.cleanTag}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('ok', `Tests passed for ${this.cleanTag}`);
|
|
||||||
} else {
|
} else {
|
||||||
logger.log('warn', `Skipping tests for ${this.cleanTag} because no test file was found at ${testFile}`);
|
logger.log('warn', `Skipping tests for ${this.cleanTag} — no test file at ${testFile}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Date.now() - startTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -67,23 +67,15 @@ export class TsDockerCache {
|
|||||||
const entry = this.data.entries[cleanTag];
|
const entry = this.data.entries[cleanTag];
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
console.log(`[cache] ${cleanTag}:`);
|
logger.log('info', `[cache] ${cleanTag}: no cached entry, will build`);
|
||||||
console.log(`[cache] Content hash: ${contentHash}`);
|
|
||||||
console.log(`[cache] Stored hash: (none)`);
|
|
||||||
console.log(`[cache] Hash match: no`);
|
|
||||||
console.log(`→ Building ${cleanTag}`);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashMatch = entry.contentHash === contentHash;
|
const hashMatch = entry.contentHash === contentHash;
|
||||||
console.log(`[cache] ${cleanTag}:`);
|
logger.log('info', `[cache] ${cleanTag}: hash ${hashMatch ? 'matches' : 'changed'}`);
|
||||||
console.log(`[cache] Content hash: ${contentHash}`);
|
|
||||||
console.log(`[cache] Stored hash: ${entry.contentHash}`);
|
|
||||||
console.log(`[cache] Image ID: ${entry.imageId}`);
|
|
||||||
console.log(`[cache] Hash match: ${hashMatch ? 'yes' : 'no'}`);
|
|
||||||
|
|
||||||
if (!hashMatch) {
|
if (!hashMatch) {
|
||||||
console.log(`→ Building ${cleanTag}`);
|
logger.log('info', `[cache] ${cleanTag}: content changed, will build`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,14 +84,13 @@ export class TsDockerCache {
|
|||||||
`docker image inspect ${entry.imageId} > /dev/null 2>&1`
|
`docker image inspect ${entry.imageId} > /dev/null 2>&1`
|
||||||
);
|
);
|
||||||
const available = inspectResult.exitCode === 0;
|
const available = inspectResult.exitCode === 0;
|
||||||
console.log(`[cache] Available: ${available ? 'yes' : 'no'}`);
|
|
||||||
|
|
||||||
if (available) {
|
if (available) {
|
||||||
console.log(`→ Skipping build for ${cleanTag} (cache hit)`);
|
logger.log('info', `[cache] ${cleanTag}: cache hit, skipping build`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`→ Building ${cleanTag} (image no longer available)`);
|
logger.log('info', `[cache] ${cleanTag}: image no longer available, will build`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from './tsdocker.plugins.js';
|
import * as plugins from './tsdocker.plugins.js';
|
||||||
import * as paths from './tsdocker.paths.js';
|
import * as paths from './tsdocker.paths.js';
|
||||||
import { logger } from './tsdocker.logging.js';
|
import { logger, formatDuration } from './tsdocker.logging.js';
|
||||||
import { Dockerfile } from './classes.dockerfile.js';
|
import { Dockerfile } from './classes.dockerfile.js';
|
||||||
import { DockerRegistry } from './classes.dockerregistry.js';
|
import { DockerRegistry } from './classes.dockerregistry.js';
|
||||||
import { RegistryStorage } from './classes.registrystorage.js';
|
import { RegistryStorage } from './classes.registrystorage.js';
|
||||||
@@ -132,49 +132,91 @@ export class TsDockerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if buildx is needed
|
// Check if buildx is needed
|
||||||
if (options?.platform || (this.config.platforms && this.config.platforms.length > 1)) {
|
const useBuildx = !!(options?.platform || (this.config.platforms && this.config.platforms.length > 1));
|
||||||
|
if (useBuildx) {
|
||||||
await this.ensureBuildx();
|
await this.ensureBuildx();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('info', `Building ${toBuild.length} Dockerfiles...`);
|
logger.log('info', '');
|
||||||
|
logger.log('info', '=== BUILD PHASE ===');
|
||||||
|
|
||||||
|
if (useBuildx) {
|
||||||
|
const platforms = options?.platform || this.config.platforms!.join(', ');
|
||||||
|
logger.log('info', `Build mode: buildx multi-platform [${platforms}]`);
|
||||||
|
} else {
|
||||||
|
logger.log('info', 'Build mode: standard docker build');
|
||||||
|
}
|
||||||
|
|
||||||
|
const localDeps = toBuild.filter(df => df.localBaseImageDependent);
|
||||||
|
if (localDeps.length > 0) {
|
||||||
|
logger.log('info', `Local dependencies: ${localDeps.map(df => `${df.cleanTag} -> ${df.localBaseDockerfile?.cleanTag}`).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.noCache) {
|
||||||
|
logger.log('info', 'Cache: disabled (--no-cache)');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', `Building ${toBuild.length} Dockerfile(s)...`);
|
||||||
|
|
||||||
if (options?.cached) {
|
if (options?.cached) {
|
||||||
// === CACHED MODE: skip builds for unchanged Dockerfiles ===
|
// === CACHED MODE: skip builds for unchanged Dockerfiles ===
|
||||||
logger.log('info', '=== CACHED MODE ACTIVE ===');
|
logger.log('info', '(cached mode active)');
|
||||||
const cache = new TsDockerCache();
|
const cache = new TsDockerCache();
|
||||||
cache.load();
|
cache.load();
|
||||||
|
|
||||||
for (const dockerfileArg of toBuild) {
|
const total = toBuild.length;
|
||||||
const skip = await cache.shouldSkipBuild(dockerfileArg.cleanTag, dockerfileArg.content);
|
const overallStart = Date.now();
|
||||||
if (skip) {
|
const useRegistry = Dockerfile.needsLocalRegistry(toBuild, options);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache miss — build this Dockerfile
|
if (useRegistry) {
|
||||||
await dockerfileArg.build({
|
await Dockerfile.startLocalRegistry();
|
||||||
platform: options?.platform,
|
|
||||||
timeout: options?.timeout,
|
|
||||||
noCache: options?.noCache,
|
|
||||||
});
|
|
||||||
|
|
||||||
const imageId = await dockerfileArg.getId();
|
|
||||||
cache.recordBuild(dockerfileArg.cleanTag, dockerfileArg.content, imageId, dockerfileArg.buildTag);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform dependency tagging for all Dockerfiles (even cache hits, since tags may be stale)
|
try {
|
||||||
for (const dockerfileArg of toBuild) {
|
for (let i = 0; i < total; i++) {
|
||||||
const dependentBaseImages = new Set<string>();
|
const dockerfileArg = toBuild[i];
|
||||||
for (const other of toBuild) {
|
const progress = `(${i + 1}/${total})`;
|
||||||
if (other.localBaseDockerfile === dockerfileArg && other.baseImage !== dockerfileArg.buildTag) {
|
const skip = await cache.shouldSkipBuild(dockerfileArg.cleanTag, dockerfileArg.content);
|
||||||
dependentBaseImages.add(other.baseImage);
|
|
||||||
|
if (skip) {
|
||||||
|
logger.log('ok', `${progress} Skipped ${dockerfileArg.cleanTag} (cached)`);
|
||||||
|
} else {
|
||||||
|
logger.log('info', `${progress} Building ${dockerfileArg.cleanTag}...`);
|
||||||
|
const elapsed = await dockerfileArg.build({
|
||||||
|
platform: options?.platform,
|
||||||
|
timeout: options?.timeout,
|
||||||
|
noCache: options?.noCache,
|
||||||
|
verbose: options?.verbose,
|
||||||
|
});
|
||||||
|
logger.log('ok', `${progress} Built ${dockerfileArg.cleanTag} in ${formatDuration(elapsed)}`);
|
||||||
|
const imageId = await dockerfileArg.getId();
|
||||||
|
cache.recordBuild(dockerfileArg.cleanTag, dockerfileArg.content, imageId, dockerfileArg.buildTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag for dependents IMMEDIATELY (not after all builds)
|
||||||
|
const dependentBaseImages = new Set<string>();
|
||||||
|
for (const other of toBuild) {
|
||||||
|
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 to local registry for buildx (even for cache hits — image exists but registry doesn't)
|
||||||
|
if (useRegistry && toBuild.some(other => other.localBaseDockerfile === dockerfileArg)) {
|
||||||
|
await Dockerfile.pushToLocalRegistry(dockerfileArg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const fullTag of dependentBaseImages) {
|
} finally {
|
||||||
logger.log('info', `Tagging ${dockerfileArg.buildTag} as ${fullTag} for local dependency resolution`);
|
if (useRegistry) {
|
||||||
await smartshellInstance.exec(`docker tag ${dockerfileArg.buildTag} ${fullTag}`);
|
await Dockerfile.stopLocalRegistry();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.log('info', `Total build time: ${formatDuration(Date.now() - overallStart)}`);
|
||||||
cache.save();
|
cache.save();
|
||||||
} else {
|
} else {
|
||||||
// === STANDARD MODE: build all via static helper ===
|
// === STANDARD MODE: build all via static helper ===
|
||||||
@@ -182,6 +224,7 @@ export class TsDockerManager {
|
|||||||
platform: options?.platform,
|
platform: options?.platform,
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
noCache: options?.noCache,
|
noCache: options?.noCache,
|
||||||
|
verbose: options?.verbose,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,22 +254,30 @@ export class TsDockerManager {
|
|||||||
* Ensures Docker buildx is set up for multi-architecture builds
|
* Ensures Docker buildx is set up for multi-architecture builds
|
||||||
*/
|
*/
|
||||||
private async ensureBuildx(): Promise<void> {
|
private async ensureBuildx(): Promise<void> {
|
||||||
logger.log('info', 'Setting up Docker buildx for multi-platform builds...');
|
const platforms = this.config.platforms?.join(', ') || 'default';
|
||||||
|
logger.log('info', `Setting up Docker buildx for multi-platform builds [${platforms}]...`);
|
||||||
// Check if a buildx builder exists
|
|
||||||
const inspectResult = await smartshellInstance.exec('docker buildx inspect tsdocker-builder 2>/dev/null');
|
const inspectResult = await smartshellInstance.exec('docker buildx inspect tsdocker-builder 2>/dev/null');
|
||||||
|
|
||||||
if (inspectResult.exitCode !== 0) {
|
if (inspectResult.exitCode !== 0) {
|
||||||
// Create a new buildx builder
|
logger.log('info', 'Creating new buildx builder with host network...');
|
||||||
logger.log('info', 'Creating new buildx builder...');
|
await smartshellInstance.exec(
|
||||||
await smartshellInstance.exec('docker buildx create --name tsdocker-builder --use');
|
'docker buildx create --name tsdocker-builder --driver docker-container --driver-opt network=host --use'
|
||||||
|
);
|
||||||
await smartshellInstance.exec('docker buildx inspect --bootstrap');
|
await smartshellInstance.exec('docker buildx inspect --bootstrap');
|
||||||
} else {
|
} else {
|
||||||
// Use existing builder
|
const inspectOutput = inspectResult.stdout || '';
|
||||||
await smartshellInstance.exec('docker buildx use tsdocker-builder');
|
if (!inspectOutput.includes('network=host')) {
|
||||||
|
logger.log('info', 'Recreating buildx builder with host network (migration)...');
|
||||||
|
await smartshellInstance.exec('docker buildx rm tsdocker-builder 2>/dev/null');
|
||||||
|
await smartshellInstance.exec(
|
||||||
|
'docker buildx create --name tsdocker-builder --driver docker-container --driver-opt network=host --use'
|
||||||
|
);
|
||||||
|
await smartshellInstance.exec('docker buildx inspect --bootstrap');
|
||||||
|
} else {
|
||||||
|
await smartshellInstance.exec('docker buildx use tsdocker-builder');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
logger.log('ok', `Docker buildx ready (platforms: ${platforms})`);
|
||||||
logger.log('ok', 'Docker buildx ready');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -308,6 +359,8 @@ export class TsDockerManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.log('info', '');
|
||||||
|
logger.log('info', '=== TEST PHASE ===');
|
||||||
await Dockerfile.testDockerfiles(this.dockerfiles);
|
await Dockerfile.testDockerfiles(this.dockerfiles);
|
||||||
logger.log('success', 'All tests completed');
|
logger.log('success', 'All tests completed');
|
||||||
}
|
}
|
||||||
@@ -320,19 +373,21 @@ export class TsDockerManager {
|
|||||||
await this.discoverDockerfiles();
|
await this.discoverDockerfiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\nDiscovered Dockerfiles:');
|
logger.log('info', '');
|
||||||
console.log('========================\n');
|
logger.log('info', 'Discovered Dockerfiles:');
|
||||||
|
logger.log('info', '========================');
|
||||||
|
logger.log('info', '');
|
||||||
|
|
||||||
for (let i = 0; i < this.dockerfiles.length; i++) {
|
for (let i = 0; i < this.dockerfiles.length; i++) {
|
||||||
const df = this.dockerfiles[i];
|
const df = this.dockerfiles[i];
|
||||||
console.log(`${i + 1}. ${df.filePath}`);
|
logger.log('info', `${i + 1}. ${df.filePath}`);
|
||||||
console.log(` Tag: ${df.cleanTag}`);
|
logger.log('info', ` Tag: ${df.cleanTag}`);
|
||||||
console.log(` Base Image: ${df.baseImage}`);
|
logger.log('info', ` Base Image: ${df.baseImage}`);
|
||||||
console.log(` Version: ${df.version}`);
|
logger.log('info', ` Version: ${df.version}`);
|
||||||
if (df.localBaseImageDependent) {
|
if (df.localBaseImageDependent) {
|
||||||
console.log(` Depends on: ${df.localBaseDockerfile?.cleanTag}`);
|
logger.log('info', ` Depends on: ${df.localBaseDockerfile?.cleanTag}`);
|
||||||
}
|
}
|
||||||
console.log('');
|
logger.log('info', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.dockerfiles;
|
return this.dockerfiles;
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export interface IBuildCommandOptions {
|
|||||||
timeout?: number; // Build timeout in seconds
|
timeout?: number; // Build timeout in seconds
|
||||||
noCache?: boolean; // Force rebuild without Docker layer cache (--no-cache)
|
noCache?: boolean; // Force rebuild without Docker layer cache (--no-cache)
|
||||||
cached?: boolean; // Skip builds when Dockerfile content hasn't changed
|
cached?: boolean; // Skip builds when Dockerfile content hasn't changed
|
||||||
|
verbose?: boolean; // Stream raw docker build output (default: silent)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICacheEntry {
|
export interface ICacheEntry {
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ export let run = () => {
|
|||||||
if (argvArg.cached) {
|
if (argvArg.cached) {
|
||||||
buildOptions.cached = true;
|
buildOptions.cached = true;
|
||||||
}
|
}
|
||||||
|
if (argvArg.verbose) {
|
||||||
|
buildOptions.verbose = true;
|
||||||
|
}
|
||||||
|
|
||||||
await manager.build(buildOptions);
|
await manager.build(buildOptions);
|
||||||
logger.log('success', 'Build completed successfully');
|
logger.log('success', 'Build completed successfully');
|
||||||
@@ -89,6 +92,9 @@ export let run = () => {
|
|||||||
if (argvArg.cache === false) {
|
if (argvArg.cache === false) {
|
||||||
buildOptions.noCache = true;
|
buildOptions.noCache = true;
|
||||||
}
|
}
|
||||||
|
if (argvArg.verbose) {
|
||||||
|
buildOptions.verbose = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Build images first (if not already built)
|
// Build images first (if not already built)
|
||||||
await manager.build(buildOptions);
|
await manager.build(buildOptions);
|
||||||
@@ -148,6 +154,9 @@ export let run = () => {
|
|||||||
if (argvArg.cached) {
|
if (argvArg.cached) {
|
||||||
buildOptions.cached = true;
|
buildOptions.cached = true;
|
||||||
}
|
}
|
||||||
|
if (argvArg.verbose) {
|
||||||
|
buildOptions.verbose = true;
|
||||||
|
}
|
||||||
await manager.build(buildOptions);
|
await manager.build(buildOptions);
|
||||||
|
|
||||||
// Run tests
|
// Run tests
|
||||||
|
|||||||
@@ -15,3 +15,12 @@ export const logger = new plugins.smartlog.Smartlog({
|
|||||||
logger.addLogDestination(new plugins.smartlogDestinationLocal.DestinationLocal());
|
logger.addLogDestination(new plugins.smartlogDestinationLocal.DestinationLocal());
|
||||||
|
|
||||||
export const ora = new plugins.smartlogSouceOra.SmartlogSourceOra();
|
export const ora = new plugins.smartlogSouceOra.SmartlogSourceOra();
|
||||||
|
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
const totalSeconds = ms / 1000;
|
||||||
|
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`;
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = Math.round(totalSeconds % 60);
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user