feat(buildx): add automatic Buildx cleanup for stale builders and end-of-run cache pruning
This commit is contained in:
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
## Pending
|
## Pending
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add automatic Buildx cleanup to prevent unbounded tsdocker builder cache growth.
|
||||||
|
- Run startup maintenance for stale `tsdocker-builder-*` builders.
|
||||||
|
- Prune active builder cache after build, push, and test commands even when commands fail.
|
||||||
|
- Remove session-specific CI builders automatically and expose cleanup policy overrides.
|
||||||
|
- Upgrade release/build tooling patch versions.
|
||||||
|
- add automatic Buildx cleanup for stale builders and end-of-run cache pruning (buildx)
|
||||||
|
- run startup cleanup for stale tsdocker Buildx builders
|
||||||
|
- prune active builder cache after build, push, and test commands even when they fail
|
||||||
|
- remove session-specific CI builders automatically with config and environment overrides
|
||||||
|
- bump release and build tooling patch versions
|
||||||
|
|
||||||
## 2026-05-13 - 2.3.0
|
## 2026-05-13 - 2.3.0
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -36,9 +36,9 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://gitlab.com/gitzone/tsdocker#readme",
|
"homepage": "https://gitlab.com/gitzone/tsdocker#readme",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^4.4.0",
|
"@git.zone/tsbuild": "^4.4.2",
|
||||||
"@git.zone/tsrun": "^2.0.3",
|
"@git.zone/tsrun": "^2.0.4",
|
||||||
"@git.zone/tstest": "^3.6.5",
|
"@git.zone/tstest": "^3.6.6",
|
||||||
"@types/node": "^25.6.2"
|
"@types/node": "^25.6.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Generated
+140
-2029
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,8 @@ const smartshellInstance = new plugins.smartshell.Smartshell({
|
|||||||
executor: 'bash',
|
executor: 'bash',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shellQuote = (value: string): string => `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main orchestrator class for Docker operations
|
* Main orchestrator class for Docker operations
|
||||||
*/
|
*/
|
||||||
@@ -478,6 +480,134 @@ export class TsDockerManager {
|
|||||||
this.activeRemoteBuilders = [];
|
this.activeRemoteBuilders = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readBooleanConfig(envName: string, configValue: boolean | undefined, defaultValue: boolean): boolean {
|
||||||
|
const envValue = process.env[envName]?.toLowerCase();
|
||||||
|
if (envValue) {
|
||||||
|
if (['1', 'true', 'yes', 'y', 'on'].includes(envValue)) return true;
|
||||||
|
if (['0', 'false', 'no', 'n', 'off'].includes(envValue)) return false;
|
||||||
|
}
|
||||||
|
return configValue ?? defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readCleanupString(envName: string, configValue: string | undefined, defaultValue: string): string | undefined {
|
||||||
|
const rawValue = process.env[envName] ?? configValue ?? defaultValue;
|
||||||
|
const normalized = rawValue.trim().toLowerCase();
|
||||||
|
if (!normalized || ['0', 'false', 'no', 'off', 'none', 'never'].includes(normalized)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return rawValue.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCleanupTimeoutSeconds(): number {
|
||||||
|
const parsed = Number(process.env.TSDOCKER_CLEANUP_TIMEOUT || '120');
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
private withCleanupTimeout(command: string): string {
|
||||||
|
return `timeout ${this.getCleanupTimeoutSeconds()}s ${command}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAutoCleanupEnabled(): boolean {
|
||||||
|
return this.readBooleanConfig('TSDOCKER_AUTO_CLEANUP', this.config.autoCleanup, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCleanupOnStartEnabled(): boolean {
|
||||||
|
return this.readBooleanConfig('TSDOCKER_CLEANUP_ON_START', this.config.cleanupOnStart, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldRemoveCiBuilders(): boolean {
|
||||||
|
return this.readBooleanConfig('TSDOCKER_REMOVE_CI_BUILDERS', this.config.removeCiBuilders, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBuildxPruneUntil(): string | undefined {
|
||||||
|
return this.readCleanupString('TSDOCKER_BUILDX_PRUNE_UNTIL', this.config.buildxPruneUntil, '168h');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBuildxPruneMaxUsedSpace(): string | undefined {
|
||||||
|
return this.readCleanupString('TSDOCKER_BUILDX_PRUNE_MAX_USED_SPACE', this.config.buildxPruneMaxUsedSpace, '2gb');
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTsdockerBuildxName(name: string): boolean {
|
||||||
|
return name === 'tsdocker-builder' || name.startsWith('tsdocker-builder-');
|
||||||
|
}
|
||||||
|
|
||||||
|
private isBuildxNodeName(name: string, allNames: Set<string>): boolean {
|
||||||
|
return name.endsWith('0') && allNames.has(name.slice(0, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPruneCommands(builderName: string): string[] {
|
||||||
|
const builderFlag = `--builder ${shellQuote(builderName)}`;
|
||||||
|
const baseCommand = `docker buildx prune ${builderFlag} --all --force`;
|
||||||
|
const commands: string[] = [];
|
||||||
|
const pruneUntil = this.getBuildxPruneUntil();
|
||||||
|
const maxUsedSpace = this.getBuildxPruneMaxUsedSpace();
|
||||||
|
|
||||||
|
if (pruneUntil) {
|
||||||
|
commands.push(`${baseCommand} --filter ${shellQuote(`until=${pruneUntil}`)}`);
|
||||||
|
}
|
||||||
|
if (maxUsedSpace) {
|
||||||
|
commands.push(`${baseCommand} --max-used-space ${shellQuote(maxUsedSpace)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pruneBuildxBuilder(builderName: string, reason: string): Promise<void> {
|
||||||
|
const commands = this.getPruneCommands(builderName);
|
||||||
|
if (commands.length === 0) return;
|
||||||
|
|
||||||
|
logger.log('info', `Buildx cleanup (${reason}): pruning cache for ${builderName}`);
|
||||||
|
for (const command of commands) {
|
||||||
|
const result = await smartshellInstance.execSilent(this.withCleanupTimeout(command));
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
logger.log('warn', `Buildx cleanup skipped for ${builderName}: ${result.stderr || 'command failed'}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMatch = result.stdout?.match(/Total:\s*(.+)$/m);
|
||||||
|
if (totalMatch?.[1]) {
|
||||||
|
logger.log('ok', `Buildx cleanup ${builderName}: reclaimed ${totalMatch[1].trim()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removeBuildxBuilder(builderName: string): Promise<void> {
|
||||||
|
logger.log('info', `Buildx cleanup: removing CI builder ${builderName}`);
|
||||||
|
const result = await smartshellInstance.execSilent(
|
||||||
|
this.withCleanupTimeout(`docker buildx rm --force ${shellQuote(builderName)} 2>/dev/null`)
|
||||||
|
);
|
||||||
|
if (result.exitCode === 0) {
|
||||||
|
logger.log('ok', `Buildx cleanup: removed ${builderName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs lightweight startup maintenance so interrupted older runs do not leave
|
||||||
|
* unbounded tsdocker BuildKit cache behind.
|
||||||
|
*/
|
||||||
|
public async cleanupStaleBuilders(): Promise<void> {
|
||||||
|
if (!this.isAutoCleanupEnabled() || !this.isCleanupOnStartEnabled()) return;
|
||||||
|
|
||||||
|
const listResult = await smartshellInstance.execSilent(`docker buildx ls --format '{{.Name}}' 2>/dev/null`);
|
||||||
|
if (listResult.exitCode !== 0 || !listResult.stdout?.trim()) {
|
||||||
|
logger.log('warn', 'Buildx startup cleanup skipped: could not list builders');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allNames = new Set(listResult.stdout.trim().split('\n').map((line) => line.trim()).filter(Boolean));
|
||||||
|
const builderNames = [...allNames]
|
||||||
|
.filter((name) => this.isTsdockerBuildxName(name))
|
||||||
|
.filter((name) => !this.isBuildxNodeName(name, allNames))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
if (builderNames.length === 0) return;
|
||||||
|
|
||||||
|
logger.log('info', `Buildx startup cleanup: found ${builderNames.length} tsdocker builder(s)`);
|
||||||
|
for (const builderName of builderNames) {
|
||||||
|
await this.pruneBuildxBuilder(builderName, 'startup');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens SSH reverse tunnels for remote builders so they can reach the local registry.
|
* Opens SSH reverse tunnels for remote builders so they can reach the local registry.
|
||||||
*/
|
*/
|
||||||
@@ -635,15 +765,22 @@ export class TsDockerManager {
|
|||||||
return this.dockerfiles;
|
return this.dockerfiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Cleans up session-specific resources and bounds persistent BuildKit cache. */
|
||||||
* Cleans up session-specific resources.
|
|
||||||
* In CI, removes the session-specific buildx builder to avoid accumulation.
|
|
||||||
*/
|
|
||||||
public async cleanup(): Promise<void> {
|
public async cleanup(): Promise<void> {
|
||||||
if (this.session?.config.isCI && this.session.config.builderSuffix) {
|
if (!this.isAutoCleanupEnabled()) return;
|
||||||
const builderName = this.dockerContext.getBuilderName() + this.session.config.builderSuffix;
|
|
||||||
logger.log('info', `CI cleanup: removing buildx builder ${builderName}`);
|
try {
|
||||||
await smartshellInstance.execSilent(`docker buildx rm ${builderName} 2>/dev/null || true`);
|
if (this.session?.config.isCI && this.session.config.builderSuffix && this.shouldRemoveCiBuilders()) {
|
||||||
|
const builderName = this.currentBuilderName || this.dockerContext.getBuilderName() + this.session.config.builderSuffix;
|
||||||
|
await this.removeBuildxBuilder(builderName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentBuilderName) {
|
||||||
|
await this.pruneBuildxBuilder(this.currentBuilderName, 'end-of-run');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.log('warn', `Buildx cleanup failed: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export interface ITsDockerConfig {
|
|||||||
platforms?: string[]; // ['linux/amd64', 'linux/arm64']
|
platforms?: string[]; // ['linux/amd64', 'linux/arm64']
|
||||||
push?: boolean;
|
push?: boolean;
|
||||||
testDir?: string;
|
testDir?: string;
|
||||||
|
autoCleanup?: boolean; // Automatically prune/remove tsdocker buildx resources
|
||||||
|
cleanupOnStart?: boolean; // Run startup maintenance for stale tsdocker builders
|
||||||
|
buildxPruneUntil?: string; // Prune cache older than this duration, e.g. '168h'; use '0' to disable
|
||||||
|
buildxPruneMaxUsedSpace?: string; // Keep each tsdocker builder cache below this size, e.g. '2gb'; use '0' to disable
|
||||||
|
removeCiBuilders?: boolean; // Remove session-specific CI builders after each run
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+40
-9
@@ -75,6 +75,16 @@ CONFIGURATION
|
|||||||
platforms Array of target platforms (default: ["linux/amd64"])
|
platforms Array of target platforms (default: ["linux/amd64"])
|
||||||
push Boolean, auto-push after build
|
push Boolean, auto-push after build
|
||||||
testDir Directory containing test_*.sh scripts
|
testDir Directory containing test_*.sh scripts
|
||||||
|
autoCleanup Boolean, auto-prune tsdocker buildx cache (default: true)
|
||||||
|
cleanupOnStart Boolean, prune stale tsdocker builders on startup (default: true)
|
||||||
|
buildxPruneUntil Duration for age-based cache pruning (default: "168h")
|
||||||
|
buildxPruneMaxUsedSpace Per-builder cache cap (default: "2gb")
|
||||||
|
|
||||||
|
Cleanup environment overrides:
|
||||||
|
TSDOCKER_AUTO_CLEANUP=false
|
||||||
|
TSDOCKER_CLEANUP_ON_START=false
|
||||||
|
TSDOCKER_BUILDX_PRUNE_UNTIL=24h
|
||||||
|
TSDOCKER_BUILDX_PRUNE_MAX_USED_SPACE=5gb
|
||||||
|
|
||||||
Global config is stored at ~/.git.zone/tsdocker/config.json
|
Global config is stored at ~/.git.zone/tsdocker/config.json
|
||||||
and managed via the "config" command.
|
and managed via the "config" command.
|
||||||
@@ -102,10 +112,13 @@ export let run = () => {
|
|||||||
* Usage: tsdocker build [Dockerfile_patterns...] [--platform=linux/arm64] [--timeout=600]
|
* Usage: tsdocker build [Dockerfile_patterns...] [--platform=linux/arm64] [--timeout=600]
|
||||||
*/
|
*/
|
||||||
tsdockerCli.addCommand('build').subscribe(async argvArg => {
|
tsdockerCli.addCommand('build').subscribe(async argvArg => {
|
||||||
|
let manager: TsDockerManager | undefined;
|
||||||
|
let exitCode = 0;
|
||||||
try {
|
try {
|
||||||
const config = await ConfigModule.run();
|
const config = await ConfigModule.run();
|
||||||
const manager = new TsDockerManager(config);
|
manager = new TsDockerManager(config);
|
||||||
await manager.prepare(argvArg.context as string | undefined);
|
await manager.prepare(argvArg.context as string | undefined);
|
||||||
|
await manager.cleanupStaleBuilders();
|
||||||
|
|
||||||
const buildOptions: IBuildCommandOptions = {};
|
const buildOptions: IBuildCommandOptions = {};
|
||||||
const patterns = argvArg._.slice(1) as string[];
|
const patterns = argvArg._.slice(1) as string[];
|
||||||
@@ -137,11 +150,15 @@ export let run = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await manager.build(buildOptions);
|
await manager.build(buildOptions);
|
||||||
await manager.cleanup();
|
|
||||||
logger.log('success', 'Build completed successfully');
|
logger.log('success', 'Build completed successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.log('error', `Build failed: ${(err as Error).message}`);
|
logger.log('error', `Build failed: ${(err as Error).message}`);
|
||||||
process.exit(1);
|
exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await manager?.cleanup();
|
||||||
|
}
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,10 +167,13 @@ export let run = () => {
|
|||||||
* Usage: tsdocker push [Dockerfile_patterns...] [--platform=linux/arm64] [--timeout=600] [--registry=url]
|
* Usage: tsdocker push [Dockerfile_patterns...] [--platform=linux/arm64] [--timeout=600] [--registry=url]
|
||||||
*/
|
*/
|
||||||
tsdockerCli.addCommand('push').subscribe(async argvArg => {
|
tsdockerCli.addCommand('push').subscribe(async argvArg => {
|
||||||
|
let manager: TsDockerManager | undefined;
|
||||||
|
let exitCode = 0;
|
||||||
try {
|
try {
|
||||||
const config = await ConfigModule.run();
|
const config = await ConfigModule.run();
|
||||||
const manager = new TsDockerManager(config);
|
manager = new TsDockerManager(config);
|
||||||
await manager.prepare(argvArg.context as string | undefined);
|
await manager.prepare(argvArg.context as string | undefined);
|
||||||
|
await manager.cleanupStaleBuilders();
|
||||||
|
|
||||||
// Login first
|
// Login first
|
||||||
await manager.login();
|
await manager.login();
|
||||||
@@ -202,11 +222,15 @@ export let run = () => {
|
|||||||
const registries = registryArg ? [registryArg] : undefined;
|
const registries = registryArg ? [registryArg] : undefined;
|
||||||
|
|
||||||
await manager.push(registries);
|
await manager.push(registries);
|
||||||
await manager.cleanup();
|
|
||||||
logger.log('success', 'Push completed successfully');
|
logger.log('success', 'Push completed successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.log('error', `Push failed: ${(err as Error).message}`);
|
logger.log('error', `Push failed: ${(err as Error).message}`);
|
||||||
process.exit(1);
|
exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await manager?.cleanup();
|
||||||
|
}
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,10 +264,13 @@ export let run = () => {
|
|||||||
* Run container tests for all Dockerfiles
|
* Run container tests for all Dockerfiles
|
||||||
*/
|
*/
|
||||||
tsdockerCli.addCommand('test').subscribe(async argvArg => {
|
tsdockerCli.addCommand('test').subscribe(async argvArg => {
|
||||||
|
let manager: TsDockerManager | undefined;
|
||||||
|
let exitCode = 0;
|
||||||
try {
|
try {
|
||||||
const config = await ConfigModule.run();
|
const config = await ConfigModule.run();
|
||||||
const manager = new TsDockerManager(config);
|
manager = new TsDockerManager(config);
|
||||||
await manager.prepare(argvArg.context as string | undefined);
|
await manager.prepare(argvArg.context as string | undefined);
|
||||||
|
await manager.cleanupStaleBuilders();
|
||||||
|
|
||||||
// Build images first
|
// Build images first
|
||||||
const buildOptions: IBuildCommandOptions = {};
|
const buildOptions: IBuildCommandOptions = {};
|
||||||
@@ -267,11 +294,15 @@ export let run = () => {
|
|||||||
|
|
||||||
// Run tests
|
// Run tests
|
||||||
await manager.test();
|
await manager.test();
|
||||||
await manager.cleanup();
|
|
||||||
logger.log('success', 'Tests completed successfully');
|
logger.log('success', 'Tests completed successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.log('error', `Tests failed: ${(err as Error).message}`);
|
logger.log('error', `Tests failed: ${(err as Error).message}`);
|
||||||
process.exit(1);
|
exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await manager?.cleanup();
|
||||||
|
}
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ const buildConfig = async (): Promise<ITsDockerConfig> => {
|
|||||||
platforms: ['linux/amd64'],
|
platforms: ['linux/amd64'],
|
||||||
push: false,
|
push: false,
|
||||||
testDir: undefined,
|
testDir: undefined,
|
||||||
|
autoCleanup: true,
|
||||||
|
cleanupOnStart: true,
|
||||||
|
buildxPruneUntil: '168h',
|
||||||
|
buildxPruneMaxUsedSpace: '2gb',
|
||||||
|
removeCiBuilders: true,
|
||||||
});
|
});
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user