Files
pikasTech-unidesk/scripts/src/server-cleanup.ts
T
2026-06-11 16:23:06 +00:00

1035 lines
40 KiB
TypeScript

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<string, string>;
}
export interface DockerContainerInventoryItem {
id: string;
name: string;
imageRef: string;
imageId: string;
state: string;
status: string;
labels: Record<string, string>;
}
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<string, string[]>;
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<RiskLevel, "blocked">;
ageHours: number | null;
reasons: string[];
commandsToReview: string[][];
reviewChecklist: string[];
}
export interface CleanupCommandReview {
kind: "docker-image-remove";
risk: Exclude<RiskLevel, "blocked">;
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<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;
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<unknown> {
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<string, DesiredImageRef[]>();
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<string, DockerContainerInventoryItem[]>();
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<string, string[]> } {
const refs: DesiredImageRef[] = [];
const commits = deployServiceCommits();
const commitsByService = commits.reduce<Record<string, string[]>>((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<string>,
desiredRefs: Map<string, DesiredImageRef[]>,
desiredCommitsByService: Record<string, string[]>,
): 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<RiskLevel, "blocked"> {
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<RiskLevel, "blocked">, 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 !== "<none>:<none>" && tag !== "<none>@<none>");
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 !== "<none>:<none>"),
repoDigests: stringArrayValue(record.RepoDigests).filter((digest) => digest !== "<none>@<none>"),
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<string, unknown> {
const trimmed = text.trim();
if (trimmed.length === 0) return {};
return asRecord(JSON.parse(trimmed) as unknown, label);
}
function asRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`);
return value as Record<string, unknown>;
}
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<string, string> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
const result: Record<string, string> = {};
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === "string") result[key] = entry;
}
return result;
}
function chunks<T>(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<string>([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<string>();
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, `'\\''`)}'`;
}