feat: add confirmed server cleanup execution

This commit is contained in:
Codex
2026-06-11 16:16:13 +00:00
parent 01b19d9238
commit 05c9e9a7de
7 changed files with 315 additions and 24 deletions
+201 -6
View File
@@ -56,6 +56,8 @@ export interface DockerCleanupInventory {
export interface ServerCleanupPlanOptions {
minAgeHours: number;
limit: number;
confirm: boolean;
includeHighRisk: boolean;
}
export interface CleanupImageSummary {
@@ -92,6 +94,15 @@ export interface CleanupCommandReview {
reviewChecklist: string[];
}
interface DiskSnapshot {
filesystem: string;
sizeBytes: number;
usedBytes: number;
availableBytes: number;
usePercent: number;
mount: string;
}
export interface ServerCleanupPlan {
ok: boolean;
dryRun: true;
@@ -107,7 +118,7 @@ export interface ServerCleanupPlan {
dockerVolumesTouched: false;
dataDirectoriesTouched: false;
databaseCleanupIncluded: false;
liveCleanupImplemented: false;
liveCleanupImplemented: boolean;
note: string;
};
inventory: {
@@ -147,6 +158,49 @@ export interface ServerCleanupPlan {
prohibitedCommands: string[];
}
interface ServerCleanupRun {
ok: boolean;
dryRun: false;
mutation: true;
action: "server cleanup run";
scope: "docker-images-only";
observedAt: string;
options: ServerCleanupPlanOptions;
diskBefore: DiskSnapshot | null;
diskAfter: DiskSnapshot | null;
summary: {
plannedCandidateCount: number;
attemptedCount: number;
succeededCount: number;
failedCount: number;
skippedHighRiskCount: number;
estimatedReclaimBytes: number;
actualDiskReclaimBytes: number | null;
};
results: Array<{
imageId: string;
shortId: string;
repoTags: string[];
repoDigests: string[];
risk: Exclude<RiskLevel, "blocked">;
estimatedReclaimBytes: number;
command: string[];
status: "succeeded" | "failed" | "skipped";
reason?: string;
exitCode?: number | null;
stdoutTail?: string;
stderrTail?: string;
}>;
policy: {
deletionExecuted: true;
dockerPruneUsed: false;
dockerVolumesTouched: false;
dataDirectoriesTouched: false;
databaseCleanupIncluded: false;
highRiskRequiresIncludeFlag: true;
};
}
interface DeployServiceCommit {
environment: string;
serviceId: string;
@@ -156,6 +210,8 @@ interface DeployServiceCommit {
const defaultOptions: ServerCleanupPlanOptions = {
minAgeHours: 24,
limit: 200,
confirm: false,
includeHighRisk: false,
};
export function parseServerCleanupOptions(args: string[]): ServerCleanupPlanOptions {
@@ -174,8 +230,16 @@ export function parseServerCleanupOptions(args: string[]): ServerCleanupPlanOpti
if (!Number.isInteger(value) || value <= 0) throw new Error("--limit must be a positive integer");
options.limit = Math.min(value, 1000);
index += 1;
} else if (arg === "--confirm") {
options.confirm = true;
} else if (arg === "--dry-run") {
options.confirm = false;
} else if (arg === "--include-high-risk") {
options.includeHighRisk = true;
} else if (arg === "--no-high-risk") {
options.includeHighRisk = false;
} else {
throw new Error(`unknown server cleanup plan option: ${arg}`);
throw new Error(`unknown server cleanup option: ${arg}`);
}
}
return options;
@@ -186,14 +250,31 @@ export async function runServerCleanupCommand(config: UniDeskConfig, args: strin
if (action === "plan" || action === "dry-run") {
return serverCleanupPlan(config, parseServerCleanupOptions(rest));
}
if (action === "run") {
const options = parseServerCleanupOptions(rest);
if (!options.confirm) {
return {
ok: false,
error: "server-cleanup-run-requires-confirm",
dryRun: true,
mutation: false,
next: {
plan: `bun scripts/cli.ts server cleanup plan --min-age-hours ${options.minAgeHours} --limit ${options.limit}`,
confirm: `bun scripts/cli.ts server cleanup run --confirm --min-age-hours ${options.minAgeHours} --limit ${options.limit}`,
},
policy: "server cleanup run removes only listed stale Docker images; high-risk images require --include-high-risk.",
};
}
return serverCleanupRun(config, options);
}
return {
ok: false,
error: "unsupported-server-cleanup-action",
action,
supportedActions: ["plan"],
supportedActions: ["plan", "run"],
dryRunOnly: true,
mutation: false,
policy: "This task implements only server cleanup plan. Real image deletion is intentionally not implemented and must require a future explicit approval parameter.",
policy: "server cleanup run requires --confirm and removes only stale Docker images selected by the same plan classifier.",
};
}
@@ -307,8 +388,8 @@ export function buildDockerCleanupPlan(inventory: DockerCleanupInventory, option
dockerVolumesTouched: false,
dataDirectoriesTouched: false,
databaseCleanupIncluded: false,
liveCleanupImplemented: false,
note: "This command only inventories Docker images and builds a dry-run review plan. It never runs docker image rm, docker prune, docker volume rm, rm, or database cleanup.",
liveCleanupImplemented: true,
note: "This command inventories Docker images and builds a dry-run review plan. Confirmed execution is available through server cleanup run --confirm; it never runs docker prune, docker volume rm, rm, or database cleanup.",
},
inventory: {
dockerAvailable: inventory.collection.dockerAvailable,
@@ -369,6 +450,91 @@ export function buildDockerCleanupPlan(inventory: DockerCleanupInventory, option
};
}
export function serverCleanupRun(config: UniDeskConfig, options: ServerCleanupPlanOptions = defaultOptions): ServerCleanupRun {
const diskBefore = rootDiskSnapshot();
const plan = serverCleanupPlan(config, options);
const selected = plan.candidateStaleImages;
const results: ServerCleanupRun["results"] = [];
for (const image of selected) {
const command = image.commandsToReview[0] ?? imageRemoveCommand({
id: image.id,
repoTags: image.repoTags,
repoDigests: image.repoDigests,
sizeBytes: image.sizeBytes,
createdAt: image.createdAt,
labels: {},
});
if (image.risk === "high" && !options.includeHighRisk) {
results.push({
imageId: image.id,
shortId: image.shortId,
repoTags: image.repoTags,
repoDigests: image.repoDigests,
risk: image.risk,
estimatedReclaimBytes: image.sizeBytes,
command,
status: "skipped",
reason: "high-risk-requires-include-high-risk",
});
continue;
}
const remove = runCommand(command, repoRoot, { timeoutMs: 60_000 });
const presentAfterRemove = remove.exitCode === 0 ? false : dockerImagePresent(image.id);
const succeeded = remove.exitCode === 0 || presentAfterRemove === false;
results.push({
imageId: image.id,
shortId: image.shortId,
repoTags: image.repoTags,
repoDigests: image.repoDigests,
risk: image.risk,
estimatedReclaimBytes: image.sizeBytes,
command,
status: succeeded ? "succeeded" : "failed",
reason: remove.exitCode !== 0 && presentAfterRemove === false ? "image-absent-after-remove" : undefined,
exitCode: remove.exitCode,
stdoutTail: tailText(remove.stdout, 4000),
stderrTail: tailText(remove.stderr, 4000),
});
}
const diskAfter = rootDiskSnapshot();
const failedCount = results.filter((item) => item.status === "failed").length;
const succeededCount = results.filter((item) => item.status === "succeeded").length;
const skippedHighRiskCount = results.filter((item) => item.status === "skipped").length;
const attempted = results.filter((item) => item.status !== "skipped");
const actualDiskReclaimBytes = diskBefore !== null && diskAfter !== null ? diskAfter.availableBytes - diskBefore.availableBytes : null;
return {
ok: plan.ok && failedCount === 0,
dryRun: false,
mutation: true,
action: "server cleanup run",
scope: "docker-images-only",
observedAt: new Date().toISOString(),
options,
diskBefore,
diskAfter,
summary: {
plannedCandidateCount: selected.length,
attemptedCount: attempted.length,
succeededCount,
failedCount,
skippedHighRiskCount,
estimatedReclaimBytes: attempted.reduce((sum, item) => sum + item.estimatedReclaimBytes, 0),
actualDiskReclaimBytes,
},
results,
policy: {
deletionExecuted: true,
dockerPruneUsed: false,
dockerVolumesTouched: false,
dataDirectoriesTouched: false,
databaseCleanupIncluded: false,
highRiskRequiresIncludeFlag: true,
},
};
}
function collectDockerCleanupInventory(config: UniDeskConfig): DockerCleanupInventory {
const observedAt = new Date().toISOString();
const desired = collectDesiredImagePolicy(config);
@@ -829,6 +995,35 @@ function commandError(command: string[], message: string, exitCode: number | nul
return { command, message, exitCode, stderrTail: stderr.slice(-1200) };
}
function dockerImagePresent(imageId: string): boolean | null {
const inspect = runCommand(["docker", "image", "inspect", imageId], repoRoot, { timeoutMs: 15_000 });
if (inspect.exitCode === 0) return true;
const text = `${inspect.stdout}\n${inspect.stderr}`;
if (/no such image|no such object/i.test(text)) return false;
return null;
}
function rootDiskSnapshot(): DiskSnapshot | null {
const result = runCommand(["df", "-B1", "-P", "/"], repoRoot, { timeoutMs: 5000 });
if (result.exitCode !== 0) return null;
const line = result.stdout.trim().split(/\r?\n/u)[1];
if (!line) return null;
const parts = line.trim().split(/\s+/u);
if (parts.length < 6) return null;
return {
filesystem: parts[0] ?? "",
sizeBytes: Number(parts[1]),
usedBytes: Number(parts[2]),
availableBytes: Number(parts[3]),
usePercent: Number((parts[4] ?? "0").replace("%", "")),
mount: parts[5] ?? "/",
};
}
function tailText(value: string, maxChars: number): string {
return value.length <= maxChars ? value : value.slice(-maxChars);
}
function shortContainerId(id: string): string {
return id.slice(0, 12);
}