feat(buildx): add automatic Buildx cleanup for stale builders and end-of-run cache pruning
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user