150 lines
7.8 KiB
TypeScript
150 lines
7.8 KiB
TypeScript
import { buildDockerCleanupPlan, type DockerCleanupInventory } from "./src/server-cleanup";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
|
|
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
|
|
}
|
|
|
|
const observedAt = "2026-05-21T14:00:00.000Z";
|
|
|
|
const fixture: DockerCleanupInventory = {
|
|
observedAt,
|
|
images: [
|
|
{
|
|
id: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
|
repoTags: ["unidesk-backend-core:latest"],
|
|
repoDigests: [],
|
|
sizeBytes: 110 * 1024 * 1024,
|
|
createdAt: "2026-05-21T10:00:00.000Z",
|
|
labels: { "unidesk.ai/service-id": "backend-core", "unidesk.ai/source-commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
|
|
},
|
|
{
|
|
id: "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
|
repoTags: ["127.0.0.1:5000/unidesk/frontend:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
|
|
repoDigests: [],
|
|
sizeBytes: 120 * 1024 * 1024,
|
|
createdAt: "2026-05-21T08:00:00.000Z",
|
|
labels: { "unidesk.ai/service-id": "frontend", "unidesk.ai/source-commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" },
|
|
},
|
|
{
|
|
id: "sha256:3333333333333333333333333333333333333333333333333333333333333333",
|
|
repoTags: ["old-test-image:local"],
|
|
repoDigests: ["old-test-image@sha256:3333333333333333333333333333333333333333333333333333333333333333"],
|
|
sizeBytes: 500 * 1024 * 1024,
|
|
createdAt: "2026-05-19T14:00:00.000Z",
|
|
labels: {},
|
|
},
|
|
{
|
|
id: "sha256:4444444444444444444444444444444444444444444444444444444444444444",
|
|
repoTags: [],
|
|
repoDigests: [],
|
|
sizeBytes: 1024 * 1024 * 1024,
|
|
createdAt: "2026-05-18T14:00:00.000Z",
|
|
labels: {},
|
|
},
|
|
],
|
|
containers: [
|
|
{
|
|
id: "container-running-backend-core",
|
|
name: "unidesk-backend-core",
|
|
imageRef: "unidesk-backend-core:latest",
|
|
imageId: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
|
state: "running",
|
|
status: "running",
|
|
labels: {},
|
|
},
|
|
],
|
|
desiredImageRefs: [
|
|
{
|
|
ref: "127.0.0.1:5000/unidesk/frontend:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
source: "CI.json + deploy.json",
|
|
serviceId: "frontend",
|
|
reason: "current commit-pinned registry artifact",
|
|
},
|
|
],
|
|
desiredCommitsByService: {
|
|
"backend-core": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
|
|
frontend: ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
|
|
},
|
|
protectedStorage: [
|
|
{
|
|
kind: "docker-volume",
|
|
ref: "unidesk_pgdata_10gb",
|
|
risk: "blocked",
|
|
reason: "database named volume",
|
|
},
|
|
{
|
|
kind: "path",
|
|
ref: "/workspace/unidesk/.state/baidu-netdisk",
|
|
risk: "blocked",
|
|
reason: "Baidu Netdisk state",
|
|
},
|
|
{
|
|
kind: "path",
|
|
ref: "/home/ubuntu/.unidesk/registry-storage",
|
|
risk: "blocked",
|
|
reason: "registry storage",
|
|
},
|
|
],
|
|
collection: {
|
|
dockerAvailable: true,
|
|
readOnlyCommands: [["docker", "image", "ls", "-q", "--no-trunc"]],
|
|
errors: [],
|
|
},
|
|
};
|
|
|
|
export function runServerCleanupPlanContract(): JsonRecord {
|
|
const plan = buildDockerCleanupPlan(fixture, { minAgeHours: 24, limit: 20 });
|
|
const candidateIds = plan.candidateStaleImages.map((image) => image.id);
|
|
const protectedIds = plan.protectedImages.map((image) => image.id);
|
|
|
|
assertCondition(plan.ok === true, "plan should be ok", plan);
|
|
assertCondition(plan.dryRun === true && plan.mutation === false, "plan must be dry-run and non-mutating", plan.policy);
|
|
assertCondition(plan.policy.deletionExecuted === false, "plan must not execute deletion", plan.policy);
|
|
assertCondition(plan.policy.dockerPruneUsed === false, "plan must not use docker prune", plan.policy);
|
|
assertCondition(plan.policy.dockerVolumesTouched === false, "plan must not touch docker volumes", plan.policy);
|
|
assertCondition(plan.policy.databaseCleanupIncluded === false, "plan must not include database cleanup", plan.policy);
|
|
|
|
assertCondition(candidateIds.includes("sha256:3333333333333333333333333333333333333333333333333333333333333333"), "tagged stale image should be a candidate", plan.candidateStaleImages);
|
|
assertCondition(candidateIds.includes("sha256:4444444444444444444444444444444444444444444444444444444444444444"), "dangling stale image should be a candidate", plan.candidateStaleImages);
|
|
assertCondition(protectedIds.includes("sha256:1111111111111111111111111111111111111111111111111111111111111111"), "running image should be protected", plan.protectedImages);
|
|
assertCondition(protectedIds.includes("sha256:2222222222222222222222222222222222222222222222222222222222222222"), "desired deploy image should be protected", plan.protectedImages);
|
|
|
|
assertCondition(plan.candidateStaleImages.length === 2, "only stale non-protected images should be candidates", plan.candidateStaleImages);
|
|
assertCondition(plan.risk.medium === 1, "tagged stale candidate should be medium risk", plan.risk);
|
|
assertCondition(plan.risk.low === 1, "dangling stale candidate should be low risk", plan.risk);
|
|
assertCondition(plan.commandsToReview.length === 2, "commandsToReview should include candidate commands", plan.commandsToReview);
|
|
assertCondition(plan.commandsToReview.every((command) => command.requiresManualApproval === true), "commands must require manual approval", plan.commandsToReview);
|
|
const taggedCommand = plan.commandsToReview.find((command) => command.imageId === "sha256:3333333333333333333333333333333333333333333333333333333333333333");
|
|
assertCondition(taggedCommand?.command.includes("old-test-image:local"), "tagged candidate command should include reviewed tag", plan.commandsToReview);
|
|
assertCondition(taggedCommand?.command.includes("old-test-image@sha256:3333333333333333333333333333333333333333333333333333333333333333"), "tagged candidate command should include reviewed digest", plan.commandsToReview);
|
|
assertCondition(plan.commandsToReview.some((command) => command.command.includes("sha256:4444444444444444444444444444444444444444444444444444444444444444")), "dangling candidate command should use image id", plan.commandsToReview);
|
|
assertCondition(!JSON.stringify(plan.commandsToReview).includes("docker image prune"), "plan must not recommend image prune", plan.commandsToReview);
|
|
assertCondition(!JSON.stringify(plan.commandsToReview).includes("docker system prune"), "plan must not recommend system prune", plan.commandsToReview);
|
|
assertCondition(plan.prohibitedCommands.includes("docker image prune"), "image prune should be explicitly prohibited", plan.prohibitedCommands);
|
|
assertCondition(plan.prohibitedCommands.includes("docker system prune"), "system prune should be explicitly prohibited", plan.prohibitedCommands);
|
|
assertCondition(plan.protectedStorage.some((item) => item.ref === "unidesk_pgdata_10gb"), "database volume must be protected", plan.protectedStorage);
|
|
assertCondition(plan.protectedStorage.some((item) => String(item.ref).includes("baidu-netdisk")), "Baidu Netdisk state must be protected", plan.protectedStorage);
|
|
assertCondition(plan.protectedStorage.some((item) => String(item.ref).includes("registry-storage")), "registry storage must be protected", plan.protectedStorage);
|
|
assertCondition(plan.estimatedReclaimBytes === (500 * 1024 * 1024) + (1024 * 1024 * 1024), "estimated reclaim should sum candidate image sizes", plan.estimates);
|
|
|
|
return {
|
|
ok: true,
|
|
checks: [
|
|
"dry-run non-mutating policy",
|
|
"active image protected",
|
|
"deploy/CI desired image protected",
|
|
"stale candidates emitted",
|
|
"risk levels emitted",
|
|
"manual commandsToReview emitted",
|
|
"database/registry/baidu storage protected",
|
|
"prune commands absent",
|
|
],
|
|
};
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
process.stdout.write(`${JSON.stringify(runServerCleanupPlanContract(), null, 2)}\n`);
|
|
}
|