212 lines
14 KiB
TypeScript
212 lines
14 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import { rootPath } from "./src/config";
|
|
import { runArtifactRegistryCommand } from "./src/artifact-registry";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
const serviceId = "code-queue-mgr";
|
|
const repo = "https://github.com/pikasTech/unidesk";
|
|
const commit = "fee1b1b710151d827749cc4b0662b1560cbe1fd6";
|
|
const dockerfile = "src/components/microservices/code-queue-mgr/Dockerfile";
|
|
const repository = "unidesk/code-queue-mgr";
|
|
const imageRef = `127.0.0.1:5000/${repository}:${commit}`;
|
|
|
|
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(environment: "dev" | "prod", id: string): JsonRecord {
|
|
const manifest = asRecord(JSON.parse(readFileSync(rootPath("deploy.json"), "utf8")) as unknown, "deploy.json");
|
|
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 found = services.map((item, index) => asRecord(item, `${environment}.services[${index}]`)).find((item) => item.id === id);
|
|
assertCondition(found !== undefined, `deploy.json ${environment} must include ${id}`, env);
|
|
return found as JsonRecord;
|
|
}
|
|
|
|
function ciArtifact(id: string): JsonRecord {
|
|
const catalog = asRecord(JSON.parse(readFileSync(rootPath("CI.json"), "utf8")) as unknown, "CI.json");
|
|
const artifacts = asArray(catalog.artifacts, "CI.json.artifacts").map((item, index) => asRecord(item, `CI.json.artifacts[${index}]`));
|
|
const found = artifacts.find((item) => item.serviceId === id);
|
|
assertCondition(found !== undefined, `CI.json must include ${id}`, catalog);
|
|
return found as JsonRecord;
|
|
}
|
|
|
|
function assertDesiredStateAndProducer(): void {
|
|
const prod = manifestService("prod", serviceId);
|
|
const dev = manifestService("dev", serviceId);
|
|
const artifact = ciArtifact(serviceId);
|
|
const source = asRecord(artifact.source, "CI.json code-queue-mgr.source");
|
|
const image = asRecord(artifact.image, "CI.json code-queue-mgr.image");
|
|
|
|
assertCondition(prod.repo === repo, "prod code-queue-mgr repo mismatch", prod);
|
|
assertCondition(prod.commitId === commit, "prod code-queue-mgr must stay pinned to the stats endpoint commit", prod);
|
|
assertCondition(dev.repo === repo, "dev code-queue-mgr repo mismatch", dev);
|
|
assertCondition(artifact.kind === "source-build", "code-queue-mgr artifact must be source-build", artifact);
|
|
assertCondition(artifact.status === "supported", "code-queue-mgr artifact publish must be supported", artifact);
|
|
assertCondition(artifact.producer === "ci publish-user-service", "code-queue-mgr must publish through user-service artifact CI", artifact);
|
|
assertCondition(source.repo === repo, "code-queue-mgr CI source repo mismatch", source);
|
|
assertCondition(source.dockerfile === dockerfile, "code-queue-mgr CI Dockerfile mismatch", source);
|
|
assertCondition(image.repository === repository, "code-queue-mgr registry repository mismatch", image);
|
|
}
|
|
|
|
function assertStatsEndpointSourceContract(): void {
|
|
const dockerfileText = readFileSync(rootPath(dockerfile), "utf8");
|
|
const rustSource = readFileSync(rootPath("src/components/microservices/code-queue-mgr/src-rs/main.rs"), "utf8");
|
|
const statsRouteIndex = rustSource.indexOf('(Method::Get, "/api/tasks/stats")');
|
|
const statsRouteSnippet = statsRouteIndex >= 0 ? rustSource.slice(statsRouteIndex, statsRouteIndex + 900) : "";
|
|
|
|
assertCondition(dockerfileText.includes("COPY src/components/microservices/code-queue-mgr/src-rs ./src-rs"), "code-queue-mgr artifact must build the Rust runtime source", { dockerfile });
|
|
assertCondition(dockerfileText.includes('CMD ["code-queue-mgr"]'), "code-queue-mgr artifact must run the Rust binary", { dockerfile });
|
|
assertCondition(statsRouteIndex >= 0, "Rust mgr must expose /api/tasks/stats", { path: "src-rs/main.rs" });
|
|
assertCondition(statsRouteSnippet.includes("task_statistics_summary"), "Rust mgr stats route must use task_statistics_summary", { statsRouteSnippet });
|
|
assertCondition(rustSource.includes("stats_summary_contract_exposes_daily_buckets_and_totals"), "Rust mgr must keep the daily bucket stats unit contract", { path: "src-rs/main.rs" });
|
|
assertCondition(!statsRouteSnippet.includes("skipped"), "Rust mgr stats route must not ship skipped statistics", { statsRouteSnippet });
|
|
}
|
|
|
|
function assertCommonDryRun(plan: JsonRecord, deployRef: string): void {
|
|
const source = asRecord(plan.source, "dry-run source");
|
|
const registry = asRecord(plan.registry, "dry-run registry");
|
|
const build = asRecord(plan.build, "dry-run build");
|
|
const labels = asRecord(plan.requiredLabels, "dry-run labels");
|
|
const probe = asRecord(plan.registryProbe, "dry-run registryProbe");
|
|
const target = asRecord(plan.target, "dry-run target");
|
|
const liveApply = asRecord(plan.liveApply, "dry-run liveApply");
|
|
const guard = asRecord(plan.selfBootstrapGuard, "dry-run selfBootstrapGuard");
|
|
const validation = strings(plan.validation, "dry-run validation");
|
|
const excludedTargets = asArray(plan.excludedTargets, "dry-run excludedTargets").map((item, index) => asRecord(item, `excludedTargets[${index}]`));
|
|
const excludedText = JSON.stringify(excludedTargets);
|
|
|
|
assertCondition(plan.ok === true && plan.supported === true, "code-queue-mgr dry-run must be supported", plan);
|
|
assertCondition(plan.dryRun === true && plan.mutation === false, "code-queue-mgr dry-run must be non-mutating", plan);
|
|
assertCondition(plan.environment === "prod", "code-queue-mgr dry-run must target prod", plan);
|
|
assertCondition(plan.providerId === "D601", "code-queue-mgr dry-run provider mismatch", plan);
|
|
assertCondition(plan.serviceId === serviceId, "code-queue-mgr dry-run service mismatch", plan);
|
|
assertCondition(plan.commit === commit, "code-queue-mgr dry-run commit mismatch", plan);
|
|
assertCondition(plan.sourceRepo === repo, "code-queue-mgr dry-run source repo mismatch", plan);
|
|
assertCondition(plan.deployRef === deployRef, "code-queue-mgr dry-run deployRef mismatch", plan);
|
|
assertCondition(plan.sourceImage === imageRef, "code-queue-mgr dry-run source image mismatch", plan);
|
|
|
|
assertCondition(source.repo === repo && source.commit === commit && source.dockerfile === dockerfile, "code-queue-mgr source boundary mismatch", source);
|
|
assertCondition(registry.imageRef === imageRef, "code-queue-mgr registry image mismatch", registry);
|
|
assertCondition(registry.digest === null, "code-queue-mgr dry-run must not fake registry digest", registry);
|
|
assertCondition(String(registry.digestSource ?? "").includes("live apply must read this digest"), "code-queue-mgr digest source must name live registry HEAD", registry);
|
|
assertCondition(probe.method === "HEAD", "code-queue-mgr registry probe must be HEAD-only", probe);
|
|
|
|
assertCondition(build.willCompile === false, "code-queue-mgr CD must not compile", build);
|
|
assertCondition(build.willRunCargoBuild === false, "code-queue-mgr CD must not run cargo build", build);
|
|
assertCondition(build.willRunDockerBuild === false, "code-queue-mgr CD must not run docker build", build);
|
|
assertCondition(build.willRunDockerComposeBuild === false, "code-queue-mgr CD must not run docker compose build", build);
|
|
assertCondition(build.producerBoundary === "ci publish-user-service", "code-queue-mgr producer boundary mismatch", build);
|
|
|
|
assertCondition(labels["unidesk.ai/service-id"] === serviceId, "code-queue-mgr label service mismatch", labels);
|
|
assertCondition(labels["unidesk.ai/source-repo"] === repo, "code-queue-mgr label repo mismatch", labels);
|
|
assertCondition(labels["unidesk.ai/source-commit"] === commit, "code-queue-mgr label commit mismatch", labels);
|
|
assertCondition(labels["unidesk.ai/dockerfile"] === dockerfile, "code-queue-mgr label Dockerfile mismatch", labels);
|
|
|
|
assertCondition(target.kind === "compose", "code-queue-mgr target must be main-server Compose", target);
|
|
assertCondition(target.runtimeHost === "main-server", "code-queue-mgr runtime host mismatch", target);
|
|
assertCondition(target.composeService === "code-queue-mgr", "code-queue-mgr Compose service mismatch", target);
|
|
assertCondition(target.containerName === "code-queue-mgr-backend", "code-queue-mgr container mismatch", target);
|
|
assertCondition(target.targetImage === "code-queue-mgr", "code-queue-mgr target image mismatch", target);
|
|
assertCondition(target.runtimeImage === `code-queue-mgr:${commit}`, "code-queue-mgr runtime image mismatch", target);
|
|
assertCondition(target.deployEnvPrefix === "UNIDESK_CODE_QUEUE_MGR_DEPLOY", "code-queue-mgr deploy env prefix mismatch", target);
|
|
assertCondition(target.deployCommandShape === "docker compose up -d --no-build --no-deps --force-recreate code-queue-mgr", "code-queue-mgr deploy command must be single-service no-build/no-deps recreate", target);
|
|
|
|
assertCondition(liveApply.policy === "supervisor-only", "code-queue-mgr prod live apply must be supervisor-only", liveApply);
|
|
assertCondition(liveApply.allowed === false, "code-queue-mgr prod live apply must be blocked in automation", liveApply);
|
|
assertCondition(liveApply.requiresSupervisorApproval === true, "code-queue-mgr prod live apply must require supervisor approval", liveApply);
|
|
assertCondition(String(liveApply.reason ?? "").includes("explicit supervisor confirmation"), "code-queue-mgr live apply reason must name supervisor confirmation", liveApply);
|
|
assertCondition(plan.requiresSupervisorApproval === true, "code-queue-mgr prod dry-run must expose top-level supervisor approval requirement", plan);
|
|
assertCondition(guard.selfBootstrapBlocked === true, "code-queue-mgr prod dry-run must expose self-bootstrap guard", guard);
|
|
assertCondition(String(guard.targetScope ?? "").includes("code-queue-mgr-backend"), "code-queue-mgr guard must name the control-plane sidecar target", guard);
|
|
assertCondition(validation.some((line) => line.includes("deploy.commit/deploy.requestedCommit")), "code-queue-mgr validation must require health deploy commit metadata", validation);
|
|
assertCondition(excludedText.includes("code-queue"), "code-queue-mgr excluded targets must include code-queue", excludedTargets);
|
|
assertCondition(excludedText.includes("scheduler"), "code-queue-mgr excluded targets must mention scheduler", excludedTargets);
|
|
assertCondition(excludedText.includes("runner"), "code-queue-mgr excluded targets must mention runner", excludedTargets);
|
|
assertCondition(excludedText.includes("tasks"), "code-queue-mgr excluded targets must mention tasks", excludedTargets);
|
|
assertCondition(excludedText.includes("interrupts"), "code-queue-mgr excluded targets must mention interrupts", excludedTargets);
|
|
assertCondition(excludedText.includes("cancellations"), "code-queue-mgr excluded targets must mention cancellations", excludedTargets);
|
|
assertCondition(!JSON.stringify(plan).includes("server rebuild"), "code-queue-mgr dry-run must not mention server rebuild", plan);
|
|
assertCondition(!JSON.stringify(plan).includes("docker compose build"), "code-queue-mgr dry-run must not mention compose build", plan);
|
|
}
|
|
|
|
function deployApplyDryRun(): JsonRecord {
|
|
const result = spawnSync("bun", ["scripts/cli.ts", "deploy", "apply", "--env", "prod", "--service", serviceId, "--dry-run"], {
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
maxBuffer: 8 * 1024 * 1024,
|
|
});
|
|
assertCondition(result.status === 0, "deploy apply dry-run should exit 0 for prod code-queue-mgr", {
|
|
status: result.status,
|
|
stdoutTail: result.stdout.slice(-2000),
|
|
stderrTail: result.stderr.slice(-2000),
|
|
});
|
|
const envelope = asRecord(JSON.parse(result.stdout) as unknown, "deploy apply envelope");
|
|
assertCondition(envelope.ok === true, "deploy apply envelope must be ok", envelope);
|
|
const data = asRecord(envelope.data, "deploy apply data");
|
|
const results = asArray(data.results, "deploy apply results");
|
|
assertCondition(data.action === "apply", "deploy apply dry-run action mismatch", data);
|
|
assertCondition(data.environment === "prod", "deploy apply dry-run environment mismatch", data);
|
|
assertCondition(data.executor === "d601-registry-artifact-consumer", "deploy apply dry-run executor mismatch", data);
|
|
assertCondition(data.dryRun === true, "deploy apply dry-run must report dryRun=true", data);
|
|
assertCondition(results.length === 1, "deploy apply dry-run must return exactly one service result", data);
|
|
return asRecord(results[0], "deploy apply code-queue-mgr result");
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
assertDesiredStateAndProducer();
|
|
assertStatsEndpointSourceContract();
|
|
|
|
const artifactDryRun = asRecord(await runArtifactRegistryCommand([
|
|
"deploy-service",
|
|
"--env",
|
|
"prod",
|
|
"--service",
|
|
serviceId,
|
|
"--commit",
|
|
commit,
|
|
"--dry-run",
|
|
]), "artifact-registry dry-run");
|
|
assertCommonDryRun(artifactDryRun, "deploy.json#environments.prod.services.code-queue-mgr");
|
|
|
|
const deployDryRun = deployApplyDryRun();
|
|
assertCommonDryRun(deployDryRun, "origin/master:deploy.json#environments.prod.services.code-queue-mgr");
|
|
|
|
process.stdout.write(`${JSON.stringify({
|
|
ok: true,
|
|
checks: [
|
|
"prod deploy.json pins code-queue-mgr to the stats endpoint commit",
|
|
"CI.json publishes code-queue-mgr through ci publish-user-service",
|
|
"Dockerfile builds and runs the Rust mgr that exposes /api/tasks/stats without skipped=true",
|
|
"artifact-registry prod dry-run is non-mutating and single-service Compose only",
|
|
"deploy apply prod dry-run is non-mutating, supervisor-only, and excludes scheduler/runner/tasks/interrupt/cancel",
|
|
],
|
|
serviceId,
|
|
commit,
|
|
artifact: imageRef,
|
|
target: asRecord(deployDryRun.target, "deploy dry-run target"),
|
|
liveApply: asRecord(deployDryRun.liveApply, "deploy dry-run liveApply"),
|
|
}, null, 2)}\n`);
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
await main();
|
|
}
|