diff --git a/deploy.json b/deploy.json index bea541de..72d6877c 100644 --- a/deploy.json +++ b/deploy.json @@ -76,7 +76,7 @@ { "id": "decision-center", "repo": "https://github.com/pikasTech/unidesk", - "commitId": "54c1f8e165f90fa8509fda1f0c01f8c3fa82cbee" + "commitId": "b5486a61ab0aa6c227366a95d1afa68281584359" } ] }, @@ -120,7 +120,7 @@ { "id": "decision-center", "repo": "https://github.com/pikasTech/unidesk", - "commitId": "54c1f8e165f90fa8509fda1f0c01f8c3fa82cbee" + "commitId": "b5486a61ab0aa6c227366a95d1afa68281584359" }, { "id": "mdtodo", diff --git a/scripts/decision-center-desired-state-contract-test.ts b/scripts/decision-center-desired-state-contract-test.ts new file mode 100644 index 00000000..f97dd56d --- /dev/null +++ b/scripts/decision-center-desired-state-contract-test.ts @@ -0,0 +1,103 @@ +import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { rootPath } from "./src/config"; + +type JsonRecord = Record; + +const verifiedCommit = "b5486a61ab0aa6c227366a95d1afa68281584359"; + +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 findService(environment: "dev" | "prod", serviceId: 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 service = services.map((item, index) => asRecord(item, `${environment}.services[${index}]`)) + .find((item) => item.id === serviceId); + assertCondition(service !== undefined, `${environment}/${serviceId} must exist in deploy.json`); + return service as JsonRecord; +} + +function runDeployPlan(environment: "dev" | "prod", serviceId: string): JsonRecord { + const result = spawnSync("bun", [ + "scripts/cli.ts", + "artifact-registry", + "deploy-service", + "--env", + environment, + "--service", + serviceId, + "--commit", + verifiedCommit, + "--dry-run", + ], { + cwd: rootPath(), + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }); + assertCondition(result.status === 0, `artifact consumer dry-run should exit 0 for ${environment}/${serviceId}`, { + status: result.status, + stdout: result.stdout.slice(-2000), + stderr: result.stderr.slice(-2000), + }); + const envelope = asRecord(JSON.parse(result.stdout) as unknown, "artifact consumer envelope"); + return asRecord(envelope.data, "artifact consumer dry-run data"); +} + +function assertNoBuildK3sDecisionCenter(environment: "dev" | "prod", expectedDeployment: string, expectedNamespace: string): void { + const service = findService(environment, "decision-center"); + assertCondition(service.commitId === verifiedCommit, `${environment}/decision-center desired commit must match verified live commit`, service); + + const plan = runDeployPlan(environment, "decision-center"); + const registry = asRecord(plan.registry, `${environment}/decision-center registry`); + const build = asRecord(plan.build, `${environment}/decision-center build`); + const target = asRecord(plan.target, `${environment}/decision-center target`); + + assertCondition(plan.ok === true && plan.dryRun === true && plan.mutation === false, `${environment}/decision-center dry-run must be non-mutating`, plan); + assertCondition(plan.commit === verifiedCommit, `${environment}/decision-center dry-run commit mismatch`, plan); + assertCondition(plan.serviceId === "decision-center", `${environment}/decision-center service id mismatch`, plan); + assertCondition(plan.sourceImage === `127.0.0.1:5000/unidesk/decision-center:${verifiedCommit}`, `${environment}/decision-center source image mismatch`, plan); + assertCondition(registry.repository === "unidesk/decision-center", `${environment}/decision-center registry repository mismatch`, registry); + assertCondition(registry.tag === verifiedCommit, `${environment}/decision-center registry tag mismatch`, registry); + assertCondition(build.producerBoundary === "ci publish-user-service", `${environment}/decision-center producer boundary mismatch`, build); + assertCondition(build.willCompile === false, `${environment}/decision-center CD must not compile`, build); + assertCondition(build.willRunDockerBuild === false, `${environment}/decision-center CD must not docker build`, build); + assertCondition(build.willRunDockerComposeBuild === false, `${environment}/decision-center CD must not compose build`, build); + assertCondition(target.kind === "d601-k3s", `${environment}/decision-center target must be D601 k3s`, target); + assertCondition(target.namespace === expectedNamespace, `${environment}/decision-center namespace mismatch`, target); + assertCondition(target.deployment === expectedDeployment, `${environment}/decision-center deployment mismatch`, target); + assertCondition(String(target.deployCommandShape ?? "").includes("kubectl set image"), `${environment}/decision-center command shape must be k3s artifact update`, target); + assertCondition(!JSON.stringify(plan).includes("server rebuild"), `${environment}/decision-center plan must not mention server rebuild`, plan); +} + +for (const environment of ["dev", "prod"] as const) { + const frontend = findService(environment, "frontend"); + assertCondition(frontend.commitId === verifiedCommit, `${environment}/frontend desired commit must stay aligned to verified UI artifact`, frontend); +} + +assertNoBuildK3sDecisionCenter("dev", "decision-center-dev", "unidesk-dev"); +assertNoBuildK3sDecisionCenter("prod", "decision-center", "unidesk"); + +process.stdout.write(`${JSON.stringify({ + ok: true, + verifiedCommit, + checks: [ + "decision-center dev/prod desired commits match the verified live/artifact commit", + "frontend dev/prod desired commits remain aligned to the same verified UI artifact", + "decision-center dev/prod dry-run plans are D601 k3s artifact consumers", + "decision-center dry-run plans declare no compile, docker build, compose build, or server rebuild path", + ], +}, null, 2)}\n`); diff --git a/scripts/src/check.ts b/scripts/src/check.ts index 8868f24c..fd41e47a 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -289,6 +289,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("scripts/src/ci.ts"), fileItem("scripts/src/e2e.ts"), fileItem("scripts/deploy-artifact-matrix-contract-test.ts"), + fileItem("scripts/decision-center-desired-state-contract-test.ts"), fileItem("scripts/code-queue-prompt-observation-test.ts"), fileItem("scripts/gh-cli-issue-guard-contract-test.ts"), fileItem("scripts/gh-cli-pr-contract-test.ts"), @@ -310,6 +311,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(commandItem("code-queue:submit-routing-contract", ["bun", "scripts/code-queue-submit-routing-contract-test.ts"], 30_000)); items.push(commandItem("provider:runner-triage-contract", ["bun", "scripts/provider-runner-triage-contract-test.ts"], 30_000)); items.push(commandItem("deploy:artifact-matrix-contract", ["bun", "scripts/deploy-artifact-matrix-contract-test.ts"], 30_000)); + items.push(commandItem("decision-center:desired-state-contract", ["bun", "scripts/decision-center-desired-state-contract-test.ts"], 30_000)); items.push(commandItem("code-queue:active-run-heartbeat-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:active-run-heartbeat-visible"], 30_000)); items.push(commandItem("code-queue:trace-gap-not-stale", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:trace-gap-not-stale"], 30_000)); items.push(commandItem("code-queue:stale-active-owner-expired", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:stale-active-owner-expired"], 30_000)); @@ -329,6 +331,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(skippedItem("code-queue:submit-routing-contract", "Code Queue submit routing contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("provider:runner-triage-contract", "Provider runner triage contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("deploy:artifact-matrix-contract", "deploy artifact matrix contract is opt-in with script checks", "--scripts-typecheck or --full")); + items.push(skippedItem("decision-center:desired-state-contract", "Decision Center desired-state drift contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:liveness-diagnostics-fixtures", "Code Queue liveness diagnostics fixtures are opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("baidu-netdisk:artifact-guard-contract", "Baidu Netdisk artifact guard contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("artifact-registry:direct-compose-dry-run-matrix", "main-server direct artifact consumer dry-run matrix is opt-in with script checks", "--scripts-typecheck or --full"));