feat(buildx): add automatic Buildx cleanup for stale builders and end-of-run cache pruning

This commit is contained in:
2026-05-22 13:07:11 +00:00
parent e10c51f6df
commit 7246e28e3e
7 changed files with 350 additions and 2049 deletions
+145 -8
View File
@@ -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}`);
}
}
}
+5
View File
@@ -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
View File
@@ -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);
}
});
+5
View File
@@ -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;
};