import { existsSync, readFileSync } from "node:fs"; import { runCommand } from "./command"; import { type UniDeskConfig, repoRoot, rootPath } from "./config"; import { loadCiCatalog, type CiCatalogArtifact } from "./ci-catalog"; type RiskLevel = "low" | "medium" | "high" | "blocked"; export interface DockerImageInventoryItem { id: string; repoTags: string[]; repoDigests: string[]; sizeBytes: number; createdAt: string | null; labels: Record; } export interface DockerContainerInventoryItem { id: string; name: string; imageRef: string; imageId: string; state: string; status: string; labels: Record; } export interface DesiredImageRef { ref: string; source: string; serviceId?: string; reason: string; } export interface ProtectedStorageRef { kind: "docker-volume" | "path" | "policy"; ref: string; risk: RiskLevel; reason: string; } export interface DockerCleanupInventory { observedAt: string; images: DockerImageInventoryItem[]; containers: DockerContainerInventoryItem[]; desiredImageRefs: DesiredImageRef[]; desiredCommitsByService: Record; protectedStorage: ProtectedStorageRef[]; collection: { dockerAvailable: boolean; readOnlyCommands: string[][]; errors: Array<{ command?: string[]; message: string; exitCode?: number | null; stderrTail?: string }>; }; } export interface ServerCleanupPlanOptions { minAgeHours: number; limit: number; confirm: boolean; includeHighRisk: boolean; } export interface CleanupImageSummary { id: string; shortId: string; repoTags: string[]; repoDigests: string[]; sizeBytes: number; createdAt: string | null; } export interface ProtectedImageSummary extends CleanupImageSummary { risk: "blocked"; reasons: string[]; containers: Array<{ id: string; name: string; state: string; status: string; imageRef: string }>; } export interface CandidateImageSummary extends CleanupImageSummary { risk: Exclude; ageHours: number | null; reasons: string[]; commandsToReview: string[][]; reviewChecklist: string[]; } export interface CleanupCommandReview { kind: "docker-image-remove"; risk: Exclude; imageId: string; estimatedReclaimBytes: number; command: string[]; commandText: string; requiresManualApproval: true; reviewChecklist: string[]; } interface DiskSnapshot { filesystem: string; sizeBytes: number; usedBytes: number; availableBytes: number; usePercent: number; mount: string; } export interface ServerCleanupPlan { ok: boolean; dryRun: true; mutation: false; action: "server cleanup plan"; scope: "docker-images-only"; observedAt: string; options: ServerCleanupPlanOptions; policy: { deletionExecuted: false; deleteCommandsExecuted: []; dockerPruneUsed: false; dockerVolumesTouched: false; dataDirectoriesTouched: false; databaseCleanupIncluded: false; liveCleanupImplemented: boolean; note: string; }; inventory: { dockerAvailable: boolean; imageCount: number; containerCount: number; activeContainerCount: number; candidateImageCount: number; protectedImageCount: number; omittedCandidateCount: number; collectionErrors: DockerCleanupInventory["collection"]["errors"]; readOnlyCommands: string[][]; }; activeContainers: Array<{ id: string; name: string; imageRef: string; imageId: string; status: string }>; activeImages: ProtectedImageSummary[]; protectedImages: ProtectedImageSummary[]; protectedDesiredImageRefs: DesiredImageRef[]; protectedStorage: ProtectedStorageRef[]; candidateStaleImages: CandidateImageSummary[]; estimatedReclaimBytes: number; estimates: { candidateImageCount: number; estimatedReclaimBytes: number; estimatedReclaimBytesUpperBound: number; basis: string; warning: string; }; risk: { overall: "manual-review-required" | "no-candidates"; low: number; medium: number; high: number; blocked: number; }; commandsToReview: CleanupCommandReview[]; manualApprovalRequired: string[]; 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; 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; commitId: string; } const defaultOptions: ServerCleanupPlanOptions = { minAgeHours: 24, limit: 200, confirm: false, includeHighRisk: false, }; export function parseServerCleanupOptions(args: string[]): ServerCleanupPlanOptions { const options = { ...defaultOptions }; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--min-age-hours") { const raw = args[index + 1]; const value = Number(raw); if (!Number.isFinite(value) || value < 0) throw new Error("--min-age-hours must be a non-negative number"); options.minAgeHours = value; index += 1; } else if (arg === "--limit") { const raw = args[index + 1]; const value = Number(raw); 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 option: ${arg}`); } } return options; } export async function runServerCleanupCommand(config: UniDeskConfig, args: string[]): Promise { const [action = "plan", ...rest] = args; 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", "run"], dryRunOnly: true, mutation: false, policy: "server cleanup run requires --confirm and removes only stale Docker images selected by the same plan classifier.", }; } export function serverCleanupPlan(config: UniDeskConfig, options: ServerCleanupPlanOptions = defaultOptions): ServerCleanupPlan { const inventory = collectDockerCleanupInventory(config); return buildDockerCleanupPlan(inventory, options); } export function buildDockerCleanupPlan(inventory: DockerCleanupInventory, options: ServerCleanupPlanOptions = defaultOptions): ServerCleanupPlan { const normalizedDesiredRefs = new Map(); for (const desired of inventory.desiredImageRefs) { for (const variant of imageRefVariants(desired.ref)) { const existing = normalizedDesiredRefs.get(variant) ?? []; existing.push(desired); normalizedDesiredRefs.set(variant, existing); } } const containersByImage = new Map(); for (const container of inventory.containers) { if (container.imageId.length === 0) continue; const existing = containersByImage.get(container.imageId) ?? []; existing.push(container); containersByImage.set(container.imageId, existing); } const runningImageIds = new Set( inventory.containers .filter((container) => container.state === "running") .map((container) => container.imageId) .filter(Boolean), ); const protectedImages: ProtectedImageSummary[] = []; const candidateImages: CandidateImageSummary[] = []; const commandsToReview: CleanupCommandReview[] = []; for (const image of inventory.images) { const relatedContainers = containersByImage.get(image.id) ?? []; const reasons = protectedReasonsForImage(image, relatedContainers, runningImageIds, normalizedDesiredRefs, inventory.desiredCommitsByService); if (reasons.length > 0) { protectedImages.push({ ...imageSummary(image), risk: "blocked", reasons, containers: relatedContainers.map(containerSummary), }); continue; } const ageHours = imageAgeHours(image.createdAt, inventory.observedAt); if (ageHours !== null && ageHours < options.minAgeHours) { protectedImages.push({ ...imageSummary(image), risk: "blocked", reasons: [`younger-than-min-age-hours:${options.minAgeHours}`], containers: [], }); continue; } const risk = candidateRisk(image, ageHours); const reviewChecklist = reviewChecklistForCandidate(risk, image); const command = imageRemoveCommand(image); const candidate: CandidateImageSummary = { ...imageSummary(image), risk, ageHours, reasons: candidateReasons(image, ageHours), commandsToReview: [command], reviewChecklist, }; candidateImages.push(candidate); commandsToReview.push({ kind: "docker-image-remove", risk, imageId: image.id, estimatedReclaimBytes: image.sizeBytes, command, commandText: command.map(shellQuote).join(" "), requiresManualApproval: true, reviewChecklist, }); } candidateImages.sort((left, right) => right.sizeBytes - left.sizeBytes || left.shortId.localeCompare(right.shortId)); commandsToReview.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes || left.imageId.localeCompare(right.imageId)); protectedImages.sort((left, right) => right.sizeBytes - left.sizeBytes || left.shortId.localeCompare(right.shortId)); const visibleCandidates = candidateImages.slice(0, options.limit); const visibleCommands = commandsToReview.slice(0, options.limit); const activeImages = protectedImages.filter((image) => runningImageIds.has(image.id)); const estimatedReclaimBytes = candidateImages.reduce((total, image) => total + image.sizeBytes, 0); const riskCounts = candidateImages.reduce( (counts, image) => ({ ...counts, [image.risk]: counts[image.risk] + 1 }), { low: 0, medium: 0, high: 0 }, ); return { ok: inventory.collection.dockerAvailable && inventory.collection.errors.length === 0, dryRun: true, mutation: false, action: "server cleanup plan", scope: "docker-images-only", observedAt: inventory.observedAt, options, policy: { deletionExecuted: false, deleteCommandsExecuted: [], dockerPruneUsed: false, dockerVolumesTouched: false, dataDirectoriesTouched: false, databaseCleanupIncluded: false, 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, imageCount: inventory.images.length, containerCount: inventory.containers.length, activeContainerCount: inventory.containers.filter((container) => container.state === "running").length, candidateImageCount: candidateImages.length, protectedImageCount: protectedImages.length, omittedCandidateCount: Math.max(0, candidateImages.length - visibleCandidates.length), collectionErrors: inventory.collection.errors, readOnlyCommands: inventory.collection.readOnlyCommands, }, activeContainers: inventory.containers .filter((container) => container.state === "running") .map((container) => ({ id: shortContainerId(container.id), name: container.name, imageRef: container.imageRef, imageId: container.imageId, status: container.status, })), activeImages, protectedImages, protectedDesiredImageRefs: inventory.desiredImageRefs, protectedStorage: inventory.protectedStorage, candidateStaleImages: visibleCandidates, estimatedReclaimBytes, estimates: { candidateImageCount: candidateImages.length, estimatedReclaimBytes, estimatedReclaimBytesUpperBound: estimatedReclaimBytes, basis: "sum of docker image inspect .Size for candidate image IDs, counted once per image ID", warning: "Docker layers may be shared; actual reclaim can be lower. Review docker system df before any future approved deletion.", }, risk: { overall: candidateImages.length === 0 ? "no-candidates" : "manual-review-required", low: riskCounts.low, medium: riskCounts.medium, high: riskCounts.high, blocked: protectedImages.length, }, commandsToReview: visibleCommands, manualApprovalRequired: [ "Review every candidate image ID, tag, age, and risk before running any command outside this dry-run.", "Confirm the target is not a rollback artifact, emergency diagnostic image, or active provider/runtime dependency.", "Do not clean PostgreSQL, Baidu Netdisk state, registry storage, Docker volumes, or host data directories from this plan.", "Any future database cleanup requires a verified backup first; this plan deliberately excludes database cleanup.", ], prohibitedCommands: [ "docker system prune", "docker image prune", "docker builder prune", "docker volume rm", "docker compose down -v", "rm -rf .state", "rm -rf /home/ubuntu/.unidesk/registry-storage", ], }; } 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); const readOnlyCommands: string[][] = []; const errors: DockerCleanupInventory["collection"]["errors"] = []; const images = collectDockerImages(readOnlyCommands, errors); const containers = collectDockerContainers(readOnlyCommands, errors); return { observedAt, images, containers, desiredImageRefs: desired.refs, desiredCommitsByService: desired.commitsByService, protectedStorage: protectedStorageRefs(config), collection: { dockerAvailable: errors.length === 0 || images.length > 0 || containers.length > 0, readOnlyCommands, errors, }, }; } function collectDockerImages( readOnlyCommands: string[][], errors: DockerCleanupInventory["collection"]["errors"], ): DockerImageInventoryItem[] { const listCommand = ["docker", "image", "ls", "-q", "--no-trunc"]; readOnlyCommands.push(listCommand); const list = runCommand(listCommand, repoRoot, { timeoutMs: 30_000 }); if (list.exitCode !== 0) { errors.push(commandError(listCommand, "failed to list docker images", list.exitCode, list.stderr)); return []; } const imageIds = [...new Set(list.stdout.split("\n").map((line) => line.trim()).filter(Boolean))]; const images: DockerImageInventoryItem[] = []; for (const chunk of chunks(imageIds, 50)) { const inspectCommand = ["docker", "image", "inspect", ...chunk]; readOnlyCommands.push(["docker", "image", "inspect", `<${chunk.length} image ids>`]); const inspect = runCommand(inspectCommand, repoRoot, { timeoutMs: 60_000 }); if (inspect.exitCode !== 0) { errors.push(commandError(inspectCommand, "failed to inspect docker images", inspect.exitCode, inspect.stderr)); continue; } const parsed = parseJsonArray(inspect.stdout, "docker image inspect"); for (const item of parsed) images.push(imageFromInspect(item)); } return images; } function collectDockerContainers( readOnlyCommands: string[][], errors: DockerCleanupInventory["collection"]["errors"], ): DockerContainerInventoryItem[] { const psCommand = ["docker", "ps", "-a", "--no-trunc", "--format", "{{json .}}"]; readOnlyCommands.push(psCommand); const ps = runCommand(psCommand, repoRoot, { timeoutMs: 30_000 }); if (ps.exitCode !== 0) { errors.push(commandError(psCommand, "failed to list docker containers", ps.exitCode, ps.stderr)); return []; } const ids = ps.stdout .split("\n") .map((line) => { const record = parseJsonRecord(line, "docker ps"); return stringValue(record.ID); }) .filter(Boolean); if (ids.length === 0) return []; const containers: DockerContainerInventoryItem[] = []; for (const chunk of chunks(ids, 50)) { const inspectCommand = ["docker", "container", "inspect", ...chunk]; readOnlyCommands.push(["docker", "container", "inspect", `<${chunk.length} container ids>`]); const inspect = runCommand(inspectCommand, repoRoot, { timeoutMs: 60_000 }); if (inspect.exitCode !== 0) { errors.push(commandError(inspectCommand, "failed to inspect docker containers", inspect.exitCode, inspect.stderr)); continue; } const parsed = parseJsonArray(inspect.stdout, "docker container inspect"); for (const item of parsed) containers.push(containerFromInspect(item)); } return containers; } function collectDesiredImagePolicy(config: UniDeskConfig): { refs: DesiredImageRef[]; commitsByService: Record } { const refs: DesiredImageRef[] = []; const commits = deployServiceCommits(); const commitsByService = commits.reduce>((accumulator, item) => { accumulator[item.serviceId] = [...(accumulator[item.serviceId] ?? []), item.commitId]; return accumulator; }, {}); const add = (ref: string | undefined, source: string, reason: string, serviceId?: string): void => { if (ref === undefined || ref.trim().length === 0) return; refs.push({ ref: ref.trim(), source, reason, ...(serviceId === undefined ? {} : { serviceId }) }); }; for (const service of composeServiceImageRefs(config)) { add(service.ref, "docker-compose.yml", service.reason, service.serviceId); } add(config.providerGateway.upgrade.runnerImage, "config.providerGateway.upgrade.runnerImage", "provider-gateway remote upgrade runner image", "provider-gateway"); for (const microservice of config.microservices) { add(microservice.repository.artifactSource?.imageRef, "config.microservices.repository.artifactSource", "upstream image pin", microservice.id); add(microservice.repository.artifactSource?.mirrorRepository, "config.microservices.repository.artifactSource", "upstream mirror repository", microservice.id); if (looksLikeImageRef(microservice.repository.dockerfile)) { add(microservice.repository.dockerfile, "config.microservices.repository.dockerfile", "repository dockerfile field is an upstream image reference", microservice.id); } if (microservice.providerId === config.providerGateway.id || microservice.deployment.mode === "internal-sidecar") { add(microservice.repository.composeService, "config.microservices.repository.composeService", "main-server or internal-sidecar stable image", microservice.id); add(`unidesk-${microservice.repository.composeService}`, "config.microservices.repository.composeService", "main-server or internal-sidecar Compose default image", microservice.id); } } try { const catalog = loadCiCatalog(); for (const artifact of catalog.artifacts) { addCatalogArtifactRefs(artifact, catalog.defaults.registry, commitsByService[artifact.serviceId] ?? [], add); } } catch (error) { add("ci-catalog-unavailable-placeholder", "CI.json", `CI catalog could not be loaded: ${error instanceof Error ? error.message : String(error)}`); } for (const item of commits) { for (const stable of stableImageRefsForService(item.serviceId, config)) { add(stable, `deploy.json#environments.${item.environment}`, "current deploy desired stable image", item.serviceId); if (!hasTagOrDigest(stable)) { add(`${stable}:${item.commitId}`, `deploy.json#environments.${item.environment}`, "current deploy desired commit-tag image", item.serviceId); } } } return { refs: dedupeDesiredRefs(refs), commitsByService }; } function addCatalogArtifactRefs( artifact: CiCatalogArtifact, registry: string, commits: string[], add: (ref: string | undefined, source: string, reason: string, serviceId?: string) => void, ): void { if (artifact.kind === "source-build") { for (const commit of commits) { add(`${registry}/${artifact.image.repository}:${commit}`, "CI.json + deploy.json", "current commit-pinned registry artifact", artifact.serviceId); add(`${artifact.image.repository}:${commit}`, "CI.json + deploy.json", "current commit-pinned local artifact tag", artifact.serviceId); } return; } add(artifact.upstream.imageRef, "CI.json upstream image", "blocked upstream image pin", artifact.serviceId); add(artifact.upstream.digestRef, "CI.json upstream image", "blocked upstream image digest pin", artifact.serviceId); add(`${registry}/${artifact.upstream.mirrorRepository}:${artifact.upstream.mirrorTag}`, "CI.json upstream image", "upstream mirror tag", artifact.serviceId); add(artifact.upstream.mirrorDigestRef, "CI.json upstream image", "upstream mirror digest pin", artifact.serviceId); } function composeServiceImageRefs(config: UniDeskConfig): Array<{ ref: string; serviceId?: string; reason: string }> { const composePath = rootPath(config.docker.composeFile); if (!existsSync(composePath)) return []; const raw = readFileSync(composePath, "utf8"); const refs: Array<{ ref: string; serviceId?: string; reason: string }> = []; let inServices = false; let currentService: string | null = null; let currentHasBuild = false; for (const line of raw.split("\n")) { if (/^services:\s*$/u.test(line)) { inServices = true; continue; } if (inServices && /^[A-Za-z0-9_-]+:\s*$/u.test(line)) break; if (!inServices) continue; const serviceMatch = line.match(/^ ([A-Za-z0-9_.-]+):\s*$/u); if (serviceMatch) { if (currentService !== null && currentHasBuild) { refs.push({ ref: `${config.docker.projectName}-${currentService}`, serviceId: serviceIdForComposeService(currentService, config), reason: "Compose default build image" }); } currentService = serviceMatch[1]; currentHasBuild = false; continue; } if (currentService === null) continue; if (/^\s{4}build:\s*$/u.test(line)) currentHasBuild = true; const imageMatch = line.match(/^\s{4}image:\s*(?:"([^"]+)"|'([^']+)'|([^#\s]+))/u); if (imageMatch) { refs.push({ ref: imageMatch[1] ?? imageMatch[2] ?? imageMatch[3] ?? "", serviceId: serviceIdForComposeService(currentService, config), reason: "Compose explicit image" }); } } if (currentService !== null && currentHasBuild) { refs.push({ ref: `${config.docker.projectName}-${currentService}`, serviceId: serviceIdForComposeService(currentService, config), reason: "Compose default build image" }); } return refs.filter((item) => item.ref.length > 0); } function serviceIdForComposeService(composeService: string, config: UniDeskConfig): string | undefined { if (composeService === "backend-core") return "backend-core"; if (composeService === "frontend") return "frontend"; if (composeService === "provider-gateway") return "provider-gateway"; const microservice = config.microservices.find((item) => item.repository.composeService === composeService); return microservice?.id; } function deployServiceCommits(): DeployServiceCommit[] { const path = rootPath("deploy.json"); if (!existsSync(path)) return []; const parsed = asRecord(JSON.parse(readFileSync(path, "utf8")) as unknown, "deploy.json"); const environments = asRecord(parsed.environments, "deploy.json.environments"); const commits: DeployServiceCommit[] = []; for (const [environment, envValue] of Object.entries(environments)) { const env = asRecord(envValue, `deploy.json.environments.${environment}`); const services = Array.isArray(env.services) ? env.services : []; for (const [index, serviceValue] of services.entries()) { const service = asRecord(serviceValue, `deploy.json.environments.${environment}.services[${index}]`); const serviceId = stringValue(service.id); const commitId = stringValue(service.commitId); if (serviceId.length > 0 && /^[0-9a-f]{40}$/iu.test(commitId)) commits.push({ environment, serviceId, commitId }); } } return commits; } function stableImageRefsForService(serviceId: string, config: UniDeskConfig): string[] { if (serviceId === "backend-core") return ["unidesk-backend-core"]; if (serviceId === "frontend") return ["unidesk-frontend", "unidesk-frontend:dev"]; if (serviceId === "code-queue") return ["unidesk-code-queue", "unidesk-code-queue:dev", "unidesk-code-queue:d601"]; if (serviceId === "k3sctl-adapter") return ["unidesk-k3sctl-adapter", "unidesk-k3sctl-adapter:d601"]; if (serviceId === "decision-center") return ["unidesk-decision-center", "unidesk-decision-center:dev", "unidesk-decision-center:d601"]; if (serviceId === "mdtodo") return ["unidesk-mdtodo", "unidesk-mdtodo:dev", "unidesk-mdtodo:d601"]; if (serviceId === "claudeqq") return ["unidesk-claudeqq", "unidesk-claudeqq:dev", "unidesk-claudeqq:d601"]; if (serviceId === "findjob") return ["findjob-server"]; if (serviceId === "pipeline") return ["pipeline-v2-control"]; if (serviceId === "met-nonlinear") return ["met-nonlinear-ml", "met-nonlinear-ml:tf26"]; const microservice = config.microservices.find((item) => item.id === serviceId); if (microservice === undefined) return []; return [microservice.repository.composeService, `unidesk-${microservice.repository.composeService}`]; } function protectedStorageRefs(config: UniDeskConfig): ProtectedStorageRef[] { return [ { kind: "docker-volume", ref: config.database.volume, risk: "blocked", reason: "PostgreSQL PGDATA named volume; database cleanup requires a verified backup and is outside this image-only plan.", }, { kind: "path", ref: rootPath(".state", "baidu-netdisk"), risk: "blocked", reason: "Baidu Netdisk OAuth, transfer, and staging state must not be removed by Docker image cleanup.", }, { kind: "path", ref: "/home/ubuntu/.unidesk/registry-storage", risk: "blocked", reason: "D601 artifact registry storage is protected; registry cleanup is not part of main-server Docker image cleanup.", }, { kind: "policy", ref: "docker-volumes-and-host-data", risk: "blocked", reason: "This plan must not generate docker volume rm, compose down -v, database cleanup, or host data directory removal commands.", }, ]; } function protectedReasonsForImage( image: DockerImageInventoryItem, relatedContainers: DockerContainerInventoryItem[], runningImageIds: Set, desiredRefs: Map, desiredCommitsByService: Record, ): string[] { const reasons: string[] = []; if (runningImageIds.has(image.id)) reasons.push("used-by-running-container"); if (relatedContainers.some((container) => container.state !== "running")) reasons.push("referenced-by-stopped-container-review-container-first"); for (const ref of [...image.repoTags, ...image.repoDigests]) { const matches = desiredRefs.get(normalizeImageRef(ref)) ?? []; for (const match of matches) reasons.push(`desired-image-ref:${match.source}:${match.ref}`); } const labelService = image.labels["unidesk.ai/service-id"] ?? image.labels["unidesk.service.id"]; const labelCommit = image.labels["unidesk.ai/source-commit"] ?? image.labels["unidesk.source.commit"] ?? image.labels["org.opencontainers.image.revision"]; if (labelService !== undefined && labelCommit !== undefined && (desiredCommitsByService[labelService] ?? []).includes(labelCommit)) { reasons.push(`desired-label:${labelService}@${labelCommit}`); } return [...new Set(reasons)]; } function imageSummary(image: DockerImageInventoryItem): CleanupImageSummary { return { id: image.id, shortId: image.id.replace(/^sha256:/u, "").slice(0, 12), repoTags: image.repoTags, repoDigests: image.repoDigests, sizeBytes: image.sizeBytes, createdAt: image.createdAt, }; } function containerSummary(container: DockerContainerInventoryItem): { id: string; name: string; state: string; status: string; imageRef: string } { return { id: shortContainerId(container.id), name: container.name, state: container.state, status: container.status, imageRef: container.imageRef, }; } function candidateRisk(image: DockerImageInventoryItem, ageHours: number | null): Exclude { const refs = [...image.repoTags, ...image.repoDigests].join(" "); const hasServiceLabel = image.labels["unidesk.ai/service-id"] !== undefined || image.labels["unidesk.service.id"] !== undefined; if (hasServiceLabel || /(^|[/:-])unidesk([/:_-]|$)/iu.test(refs)) return "high"; if (image.repoTags.length > 0 || ageHours === null || ageHours < 24) return "medium"; return "low"; } function candidateReasons(image: DockerImageInventoryItem, ageHours: number | null): string[] { const reasons = ["not-used-by-any-container", "not-present-in-deploy-or-ci-protected-refs"]; if (image.repoTags.length === 0) reasons.push("dangling-or-untagged-image"); else reasons.push("tagged-image-requires-extra-review"); if (ageHours !== null) reasons.push(`age-hours:${Math.round(ageHours * 10) / 10}`); return reasons; } function reviewChecklistForCandidate(risk: Exclude, image: DockerImageInventoryItem): string[] { const checklist = [ "Confirm docker ps -a still shows no container using this image ID.", "Confirm the image is not a rollback target in deploy.json, CI.json, or recent deployment notes.", ]; if (risk === "high") checklist.push("High risk because the image looks UniDesk/service-related; require explicit operator approval before removal."); if (image.repoTags.length > 0) checklist.push("Tagged image: review every tag in the command before approving deletion."); return checklist; } function imageRemoveCommand(image: DockerImageInventoryItem): string[] { const refs = [...image.repoTags, ...image.repoDigests].filter((tag) => tag !== ":" && tag !== "@"); if (refs.length > 0) return ["docker", "image", "rm", ...refs]; return ["docker", "image", "rm", image.id]; } function imageAgeHours(createdAt: string | null, observedAt: string): number | null { if (createdAt === null) return null; const created = Date.parse(createdAt); const observed = Date.parse(observedAt); if (!Number.isFinite(created) || !Number.isFinite(observed)) return null; return Math.max(0, (observed - created) / 3_600_000); } function imageFromInspect(value: unknown): DockerImageInventoryItem { const record = asRecord(value, "docker image inspect item"); const config = asRecord(record.Config ?? {}, "docker image inspect item.Config"); return { id: stringValue(record.Id), repoTags: stringArrayValue(record.RepoTags).filter((tag) => tag !== ":"), repoDigests: stringArrayValue(record.RepoDigests).filter((digest) => digest !== "@"), sizeBytes: numberValue(record.Size), createdAt: stringValue(record.Created) || null, labels: stringRecordValue(config.Labels), }; } function containerFromInspect(value: unknown): DockerContainerInventoryItem { const record = asRecord(value, "docker container inspect item"); const config = asRecord(record.Config ?? {}, "docker container inspect item.Config"); const state = asRecord(record.State ?? {}, "docker container inspect item.State"); return { id: stringValue(record.Id), name: stringValue(record.Name).replace(/^\//u, ""), imageRef: stringValue(config.Image), imageId: stringValue(record.Image), state: stringValue(state.Status), status: stringValue(state.Status), labels: stringRecordValue(config.Labels), }; } function parseJsonArray(text: string, label: string): unknown[] { const trimmed = text.trim(); if (trimmed.length === 0) return []; const parsed = JSON.parse(trimmed) as unknown; if (!Array.isArray(parsed)) throw new Error(`${label} did not return a JSON array`); return parsed; } function parseJsonRecord(text: string, label: string): Record { const trimmed = text.trim(); if (trimmed.length === 0) return {}; return asRecord(JSON.parse(trimmed) as unknown, label); } function asRecord(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`); return value as Record; } function stringValue(value: unknown): string { return typeof value === "string" ? value : ""; } function numberValue(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? value : 0; } function stringArrayValue(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === "string" && item.length > 0); } function stringRecordValue(value: unknown): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; const result: Record = {}; for (const [key, entry] of Object.entries(value)) { if (typeof entry === "string") result[key] = entry; } return result; } function chunks(values: T[], size: number): T[][] { const result: T[][] = []; for (let index = 0; index < values.length; index += size) result.push(values.slice(index, index + size)); return result; } function imageRefVariants(ref: string): string[] { const normalized = normalizeImageRef(ref); const variants = new Set([normalized]); if (!hasTagOrDigest(normalized)) variants.add(`${normalized}:latest`); if (normalized.startsWith("docker.io/library/")) variants.add(normalized.replace(/^docker\.io\/library\//u, "")); if (normalized.startsWith("docker.io/")) variants.add(normalized.replace(/^docker\.io\//u, "")); return [...variants]; } function normalizeImageRef(ref: string): string { return ref.trim().replace(/^docker\.io\/library\//u, ""); } function hasTagOrDigest(ref: string): boolean { if (ref.includes("@")) return true; const slashIndex = ref.lastIndexOf("/"); const colonIndex = ref.lastIndexOf(":"); return colonIndex > slashIndex; } function looksLikeImageRef(value: string): boolean { if (value.endsWith("Dockerfile") || value.endsWith(".yml") || value.endsWith(".yaml") || value.endsWith(".json")) return false; return value.includes(":") || value.includes("@"); } function dedupeDesiredRefs(refs: DesiredImageRef[]): DesiredImageRef[] { const seen = new Set(); const result: DesiredImageRef[] = []; for (const ref of refs) { const key = `${ref.ref}\0${ref.source}\0${ref.reason}\0${ref.serviceId ?? ""}`; if (seen.has(key)) continue; seen.add(key); result.push(ref); } return result.sort((left, right) => left.ref.localeCompare(right.ref) || left.source.localeCompare(right.source)); } function commandError(command: string[], message: string, exitCode: number | null, stderr: string): DockerCleanupInventory["collection"]["errors"][number] { 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); } function shellQuote(value: string): string { if (/^[A-Za-z0-9_./:@=-]+$/u.test(value)) return value; return `'${value.replace(/'/g, `'\\''`)}'`; }