1035 lines
40 KiB
TypeScript
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, `'\\''`)}'`;
|
|
}
|