Files
pikasTech-unidesk/scripts/issue-9-user-service-artifact-gap-contract-test.ts
T
2026-05-21 13:32:01 +00:00

266 lines
18 KiB
TypeScript

import { readFileSync } from "node:fs";
import { rootPath } from "./src/config";
import { runArtifactRegistryCommand } from "./src/artifact-registry";
type Environment = "dev" | "prod";
type ServiceId = "mdtodo" | "claudeqq" | "todo-note";
type JsonRecord = Record<string, unknown>;
type ServiceContract = {
serviceId: ServiceId;
desiredCommit: string;
runtimeCommit: string | null;
runtimeCommitSource: string;
artifactExists: boolean;
devStatus: string;
prodStatus: string;
blockedScopes: string[];
recommendedAction: string;
sourceRepo: string;
dockerfile: string;
registryRepository: string;
consumerKind: "d601-k3s" | "compose";
};
const contracts: ServiceContract[] = [
{
serviceId: "mdtodo",
desiredCommit: "595de3d320b73ec006794440b32db48b3ad14d2b",
runtimeCommit: "75fb6757b2504ba86d61f2587fb34a9c9ed4019a",
runtimeCommitSource: "prod Deployment annotations; desired artifact target was advanced because 75fb6757 predates mdtodo /health.deploy metadata",
artifactExists: false,
devStatus: "missing-dev-service",
prodStatus: "healthy-prod-annotation-stale-after-health-metadata-repin",
blockedScopes: ["registry-artifact", "dev-service", "runtime-health-metadata-proof", "prod-runtime-commit-drift"],
recommendedAction: "Publish the desired artifact that includes mdtodo health deploy metadata, create/verify unidesk-dev/mdtodo-dev, then run focused dev smoke before deciding whether prod needs replacement.",
sourceRepo: "https://github.com/pikasTech/unidesk",
dockerfile: "src/components/microservices/mdtodo/Dockerfile",
registryRepository: "unidesk/mdtodo",
consumerKind: "d601-k3s",
},
{
serviceId: "claudeqq",
desiredCommit: "203b1f46684c91340ecbbd8a74502bd55e4f2011",
runtimeCommit: "203b1f46684c91340ecbbd8a74502bd55e4f2011",
runtimeCommitSource: "prod /health deploy.commit and deploy.requestedCommit",
artifactExists: false,
devStatus: "missing-dev-service",
prodStatus: "healthy-prod-health-aligned-event-api-unverified",
blockedScopes: ["registry-artifact", "dev-service", "event-api-surface"],
recommendedAction: "Publish the desired artifact, create/verify unidesk-dev/claudeqq-dev, then resolve or document the event API paths before prod artifact replacement.",
sourceRepo: "https://gitee.com/lyon1998/agent_skills",
dockerfile: "claudeqq/Dockerfile",
registryRepository: "unidesk/claudeqq",
consumerKind: "d601-k3s",
},
{
serviceId: "todo-note",
desiredCommit: "a14ce0eb855a685fa17b47adacd54623e72cd2ff",
runtimeCommit: null,
runtimeCommitSource: "prod health and container labels do not expose source commit",
artifactExists: false,
devStatus: "consumer-plan-only-no-live-dev",
prodStatus: "healthy-behavior-no-commit-proof",
blockedScopes: ["registry-artifact", "runtime-commit-proof", "health-deploy-metadata"],
recommendedAction: "Publish the desired artifact, then use the no-build Compose artifact consumer to recreate only todo-note and verify image labels plus health deploy metadata.",
sourceRepo: "https://gitee.com/Lyon1998/todo_note",
dockerfile: "Dockerfile",
registryRepository: "unidesk/todo-note",
consumerKind: "compose",
},
];
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function asRecord(value: unknown, label: string): JsonRecord {
assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value);
return value as JsonRecord;
}
function asArray(value: unknown, label: string): unknown[] {
assertCondition(Array.isArray(value), `${label} must be an array`, value);
return value as unknown[];
}
function strings(value: unknown, label: string): string[] {
return asArray(value, label).map(String);
}
function manifestService(manifest: JsonRecord, environment: Environment, serviceId: ServiceId): JsonRecord {
const environments = asRecord(manifest.environments, "deploy.json.environments");
const env = asRecord(environments[environment], `deploy.json.environments.${environment}`);
const services = asArray(env.services, `deploy.json.environments.${environment}.services`);
const service = services.map((item, index) => asRecord(item, `${environment}.services[${index}]`)).find((item) => item.id === serviceId);
assertCondition(service !== undefined, `deploy.json ${environment} must include ${serviceId}`, env);
return service as JsonRecord;
}
function artifactCatalogEntry(catalog: JsonRecord, serviceId: ServiceId): JsonRecord {
const artifacts = asArray(catalog.artifacts, "CI.json.artifacts").map((item, index) => asRecord(item, `CI.json.artifacts[${index}]`));
const artifact = artifacts.find((item) => item.serviceId === serviceId);
assertCondition(artifact !== undefined, `CI.json must include ${serviceId}`, catalog);
return artifact as JsonRecord;
}
function assertDeployJson(): void {
const manifest = asRecord(JSON.parse(readFileSync(rootPath("deploy.json"), "utf8")) as unknown, "deploy.json");
assertCondition(manifest.schemaVersion === 2, "deploy.json must use schemaVersion=2", manifest);
for (const contract of contracts) {
for (const environment of ["dev", "prod"] as const) {
const service = manifestService(manifest, environment, contract.serviceId);
assertCondition(service.repo === contract.sourceRepo, `${contract.serviceId} ${environment} repo mismatch`, service);
assertCondition(service.commitId === contract.desiredCommit, `${contract.serviceId} ${environment} desired commit mismatch`, service);
}
}
}
function assertCiCatalog(): void {
const catalog = asRecord(JSON.parse(readFileSync(rootPath("CI.json"), "utf8")) as unknown, "CI.json");
for (const contract of contracts) {
const artifact = artifactCatalogEntry(catalog, contract.serviceId);
const source = asRecord(artifact.source, `${contract.serviceId} CI source`);
const image = asRecord(artifact.image, `${contract.serviceId} CI image`);
assertCondition(artifact.kind === "source-build", `${contract.serviceId} producer must be source-build`, artifact);
assertCondition(artifact.status === "supported", `${contract.serviceId} producer must be supported`, artifact);
assertCondition(artifact.producer === "ci publish-user-service", `${contract.serviceId} producer command mismatch`, artifact);
assertCondition(source.repo === contract.sourceRepo, `${contract.serviceId} source repo mismatch`, source);
assertCondition(source.dockerfile === contract.dockerfile, `${contract.serviceId} Dockerfile mismatch`, source);
assertCondition(image.repository === contract.registryRepository, `${contract.serviceId} registry repository mismatch`, image);
}
}
function assertNormalizedStatus(): void {
for (const contract of contracts) {
assertCondition(/^[0-9a-f]{40}$/u.test(contract.desiredCommit), `${contract.serviceId} desiredCommit must be a full sha`, contract);
assertCondition(contract.runtimeCommit === null || /^[0-9a-f]{40}$/u.test(contract.runtimeCommit), `${contract.serviceId} runtimeCommit must be a full sha or null`, contract);
assertCondition(contract.artifactExists === false, `${contract.serviceId} desired artifact should remain recorded as missing`, contract);
assertCondition(contract.devStatus.length > 0, `${contract.serviceId} devStatus must be structured`, contract);
assertCondition(contract.prodStatus.length > 0, `${contract.serviceId} prodStatus must be structured`, contract);
assertCondition(contract.blockedScopes.length > 0, `${contract.serviceId} blockedScopes must not be empty`, contract);
assertCondition(contract.recommendedAction.length > 0, `${contract.serviceId} recommendedAction must not be empty`, contract);
}
}
function assertCommonDryRun(plan: JsonRecord, contract: ServiceContract, environment: Environment): void {
const source = asRecord(plan.source, `${contract.serviceId} ${environment} source`);
const registry = asRecord(plan.registry, `${contract.serviceId} ${environment} registry`);
const build = asRecord(plan.build, `${contract.serviceId} ${environment} build`);
const labels = asRecord(plan.requiredLabels, `${contract.serviceId} ${environment} labels`);
const registryProbe = asRecord(plan.registryProbe, `${contract.serviceId} ${environment} registryProbe`);
assertCondition(plan.ok === true && plan.supported === true, `${contract.serviceId} ${environment} dry-run must be supported`, plan);
assertCondition(plan.dryRun === true && plan.mutation === false, `${contract.serviceId} ${environment} dry-run must be non-mutating`, plan);
assertCondition(plan.environment === environment, `${contract.serviceId} ${environment} environment mismatch`, plan);
assertCondition(plan.providerId === "D601", `${contract.serviceId} ${environment} provider mismatch`, plan);
assertCondition(plan.serviceId === contract.serviceId, `${contract.serviceId} ${environment} service id mismatch`, plan);
assertCondition(plan.commit === contract.desiredCommit, `${contract.serviceId} ${environment} commit mismatch`, plan);
const deployRef = `deploy.json#environments.${environment}.services.${contract.serviceId}`;
const allowedDeployRefs = [deployRef, `origin/master:${deployRef}`];
assertCondition(allowedDeployRefs.includes(String(plan.deployRef)), `${contract.serviceId} ${environment} deployRef mismatch`, plan);
assertCondition(plan.sourceImage === `127.0.0.1:5000/${contract.registryRepository}:${contract.desiredCommit}`, `${contract.serviceId} ${environment} source image mismatch`, plan);
assertCondition(source.repo === contract.sourceRepo, `${contract.serviceId} ${environment} source repo mismatch`, source);
assertCondition(source.commit === contract.desiredCommit, `${contract.serviceId} ${environment} source commit mismatch`, source);
assertCondition(source.dockerfile === contract.dockerfile, `${contract.serviceId} ${environment} source dockerfile mismatch`, source);
assertCondition(registry.repository === contract.registryRepository, `${contract.serviceId} ${environment} registry repository mismatch`, registry);
assertCondition(registry.imageRef === plan.sourceImage, `${contract.serviceId} ${environment} registry image mismatch`, registry);
assertCondition(registry.digest === null, `${contract.serviceId} ${environment} dry-run must not fake a digest`, registry);
assertCondition(String(registry.digestSource ?? "").includes("live apply must read this digest"), `${contract.serviceId} ${environment} digest source mismatch`, registry);
assertCondition(registryProbe.method === "HEAD", `${contract.serviceId} ${environment} registry probe must be HEAD`, registryProbe);
assertCondition(labels["unidesk.ai/service-id"] === contract.serviceId, `${contract.serviceId} ${environment} service label mismatch`, labels);
assertCondition(labels["unidesk.ai/source-repo"] === contract.sourceRepo, `${contract.serviceId} ${environment} source repo label mismatch`, labels);
assertCondition(labels["unidesk.ai/source-commit"] === contract.desiredCommit, `${contract.serviceId} ${environment} commit label mismatch`, labels);
assertCondition(labels["unidesk.ai/dockerfile"] === contract.dockerfile, `${contract.serviceId} ${environment} Dockerfile label mismatch`, labels);
assertCondition(build.willCompile === false, `${contract.serviceId} ${environment} CD must not compile`, build);
assertCondition(build.willRunCargoBuild === false, `${contract.serviceId} ${environment} CD must not run cargo build`, build);
assertCondition(build.willRunDockerBuild === false, `${contract.serviceId} ${environment} CD must not run docker build`, build);
assertCondition(build.willRunDockerComposeBuild === false, `${contract.serviceId} ${environment} CD must not run docker compose build`, build);
assertCondition(build.producerBoundary === "ci publish-user-service", `${contract.serviceId} ${environment} producer boundary mismatch`, build);
assertCondition(String(plan.boundary ?? "").includes("artifact-consumer only"), `${contract.serviceId} ${environment} boundary must be artifact-only`, plan);
assertCondition(String(plan.boundary ?? "").includes("never builds source"), `${contract.serviceId} ${environment} boundary must forbid source builds`, plan);
}
function assertK3sDryRun(plan: JsonRecord, contract: ServiceContract, environment: Environment): void {
const target = asRecord(plan.target, `${contract.serviceId} ${environment} target`);
const validation = strings(plan.validation, `${contract.serviceId} ${environment} validation`);
const suffix = environment === "dev" ? "-dev" : "";
assertCondition(target.kind === "d601-k3s", `${contract.serviceId} ${environment} target kind mismatch`, target);
assertCondition(target.namespace === (environment === "dev" ? "unidesk-dev" : "unidesk"), `${contract.serviceId} ${environment} namespace mismatch`, target);
assertCondition(target.deployment === `${contract.serviceId}${suffix}`, `${contract.serviceId} ${environment} deployment mismatch`, target);
assertCondition(target.service === `${contract.serviceId}${suffix}`, `${contract.serviceId} ${environment} service mismatch`, target);
assertCondition(String(target.deployCommandShape ?? "").includes("kubectl set image"), `${contract.serviceId} ${environment} command must be k3s image update`, target);
assertCondition(validation.some((line) => line.includes("Kubernetes API service proxy") || line.includes("service health")), `${contract.serviceId} ${environment} validation should include k3s service health`, validation);
}
function assertComposeDryRun(plan: JsonRecord, contract: ServiceContract, environment: Environment): void {
const target = asRecord(plan.target, `${contract.serviceId} ${environment} target`);
const validation = strings(plan.validation, `${contract.serviceId} ${environment} validation`);
assertCondition(target.kind === "compose", `${contract.serviceId} ${environment} target kind mismatch`, target);
assertCondition(target.runtimeHost === "main-server", `${contract.serviceId} ${environment} runtime host mismatch`, target);
assertCondition(target.composeService === "todo-note", `${contract.serviceId} ${environment} compose service mismatch`, target);
assertCondition(target.containerName === "todo-note-backend", `${contract.serviceId} ${environment} container mismatch`, target);
assertCondition(target.deployCommandShape === "docker compose up -d --no-build --no-deps --force-recreate todo-note", `${contract.serviceId} ${environment} command shape mismatch`, target);
const runtimeProof = asRecord(plan.runtimeProof, `${contract.serviceId} ${environment} runtimeProof`);
const requiredEnvKeys = asArray(runtimeProof.requiredEnvKeys, `${contract.serviceId} ${environment} runtime proof env keys`).map(String);
assertCondition(runtimeProof.kind === "compose-container-runtime-metadata", `${contract.serviceId} ${environment} runtime proof kind mismatch`, runtimeProof);
assertCondition(runtimeProof.sourceDirectoryUsed === false, `${contract.serviceId} ${environment} runtime proof must not use source directory guesses`, runtimeProof);
assertCondition(requiredEnvKeys.includes("UNIDESK_DEPLOY_COMMIT"), `${contract.serviceId} ${environment} runtime proof must use generic commit env`, runtimeProof);
assertCondition(requiredEnvKeys.includes("UNIDESK_TODO_NOTE_DEPLOY_COMMIT"), `${contract.serviceId} ${environment} runtime proof must use service commit env`, runtimeProof);
assertCondition(validation.some((line) => line.includes("deploy.commit/deploy.requestedCommit") && line.includes("Compose container runtime metadata")), `${contract.serviceId} ${environment} validation must require synthetic health deploy metadata`, validation);
}
async function assertDryRuns(): Promise<void> {
for (const contract of contracts) {
for (const environment of ["dev", "prod"] as const) {
const plan = asRecord(await runArtifactRegistryCommand([
"deploy-service",
"--env",
environment,
"--service",
contract.serviceId,
"--commit",
contract.desiredCommit,
"--dry-run",
]), `${contract.serviceId} ${environment} artifact dry-run`);
assertCommonDryRun(plan, contract, environment);
if (contract.consumerKind === "d601-k3s") assertK3sDryRun(plan, contract, environment);
else assertComposeDryRun(plan, contract, environment);
}
}
}
async function main(): Promise<void> {
assertDeployJson();
assertCiCatalog();
assertNormalizedStatus();
await assertDryRuns();
process.stdout.write(`${JSON.stringify({
ok: true,
checks: [
"deploy.json dev/prod desired commits match the issue #9 service matrix",
"CI.json keeps mdtodo, claudeqq and todo-note on supported ci publish-user-service source-build producers",
"normalized status records expose desiredCommit, runtimeCommit, artifactExists, devStatus, prodStatus, blockedScopes and recommendedAction",
"dev/prod artifact-registry dry-runs are non-mutating, commit-pinned and no-build",
"mdtodo and claudeqq dry-runs target D601 k3s consumers",
"todo-note dry-runs target the main-server Compose consumer with no-build/no-deps recreate shape",
],
services: contracts.map((contract) => ({
serviceId: contract.serviceId,
desiredCommit: contract.desiredCommit,
runtimeCommit: contract.runtimeCommit,
runtimeCommitSource: contract.runtimeCommitSource,
artifactExists: contract.artifactExists,
devStatus: contract.devStatus,
prodStatus: contract.prodStatus,
blockedScopes: contract.blockedScopes,
recommendedAction: contract.recommendedAction,
})),
}, null, 2)}\n`);
}
if (import.meta.main) {
await main();
}