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
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
+3
-3
@@ -36,9 +36,9 @@
|
||||
},
|
||||
"homepage": "https://gitlab.com/gitzone/tsdocker#readme",
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsrun": "^2.0.3",
|
||||
"@git.zone/tstest": "^3.6.5",
|
||||
"@git.zone/tsbuild": "^4.4.2",
|
||||
"@git.zone/tsrun": "^2.0.4",
|
||||
"@git.zone/tstest": "^3.6.6",
|
||||
"@types/node": "^25.6.2"
|
||||
},
|
||||
"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',
|
||||
});
|
||||
|
||||
const shellQuote = (value: string): string => `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
|
||||
/**
|
||||
* Main orchestrator class for Docker operations
|
||||
*/
|
||||
@@ -478,6 +480,134 @@ export class TsDockerManager {
|
||||
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.
|
||||
*/
|
||||
@@ -635,15 +765,22 @@ export class TsDockerManager {
|
||||
return this.dockerfiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up session-specific resources.
|
||||
* In CI, removes the session-specific buildx builder to avoid accumulation.
|
||||
*/
|
||||
/** Cleans up session-specific resources and bounds persistent BuildKit cache. */
|
||||
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`);
|
||||
if (!this.isAutoCleanupEnabled()) return;
|
||||
|
||||
try {
|
||||
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']
|
||||
push?: boolean;
|
||||
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"])
|
||||
push Boolean, auto-push after build
|
||||
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
|
||||
and managed via the "config" command.
|
||||
@@ -102,10 +112,13 @@ export let run = () => {
|
||||
* Usage: tsdocker build [Dockerfile_patterns...] [--platform=linux/arm64] [--timeout=600]
|
||||
*/
|
||||
tsdockerCli.addCommand('build').subscribe(async argvArg => {
|
||||
let manager: TsDockerManager | undefined;
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const config = await ConfigModule.run();
|
||||
const manager = new TsDockerManager(config);
|
||||
manager = new TsDockerManager(config);
|
||||
await manager.prepare(argvArg.context as string | undefined);
|
||||
await manager.cleanupStaleBuilders();
|
||||
|
||||
const buildOptions: IBuildCommandOptions = {};
|
||||
const patterns = argvArg._.slice(1) as string[];
|
||||
@@ -137,11 +150,15 @@ 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}`);
|
||||
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]
|
||||
*/
|
||||
tsdockerCli.addCommand('push').subscribe(async argvArg => {
|
||||
let manager: TsDockerManager | undefined;
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const config = await ConfigModule.run();
|
||||
const manager = new TsDockerManager(config);
|
||||
manager = new TsDockerManager(config);
|
||||
await manager.prepare(argvArg.context as string | undefined);
|
||||
await manager.cleanupStaleBuilders();
|
||||
|
||||
// Login first
|
||||
await manager.login();
|
||||
@@ -202,11 +222,15 @@ 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}`);
|
||||
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
|
||||
*/
|
||||
tsdockerCli.addCommand('test').subscribe(async argvArg => {
|
||||
let manager: TsDockerManager | undefined;
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const config = await ConfigModule.run();
|
||||
const manager = new TsDockerManager(config);
|
||||
manager = new TsDockerManager(config);
|
||||
await manager.prepare(argvArg.context as string | undefined);
|
||||
await manager.cleanupStaleBuilders();
|
||||
|
||||
// Build images first
|
||||
const buildOptions: IBuildCommandOptions = {};
|
||||
@@ -267,11 +294,15 @@ 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}`);
|
||||
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'],
|
||||
push: false,
|
||||
testDir: undefined,
|
||||
autoCleanup: true,
|
||||
cleanupOnStart: true,
|
||||
buildxPruneUntil: '168h',
|
||||
buildxPruneMaxUsedSpace: '2gb',
|
||||
removeCiBuilders: true,
|
||||
});
|
||||
return config;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user