From fc87e680e447fec3c83f44f13c75314800b7736a Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 13:25:16 +0000 Subject: [PATCH] test: add deploy json executor drift preflight --- deploy.json | 38 +- docs/reference/cicd-standardization.md | 12 +- scripts/issue-60-cicd-drift-contract-test.ts | 19 +- ...y-json-executor-preflight-contract-test.ts | 127 ++++++ scripts/src/artifact-registry.ts | 122 +++++- scripts/src/deploy-json-contract.ts | 407 ++++++++++++++++++ scripts/src/deploy.ts | 146 ++++++- 7 files changed, 825 insertions(+), 46 deletions(-) create mode 100644 scripts/issue-60-deploy-json-executor-preflight-contract-test.ts create mode 100644 scripts/src/deploy-json-contract.ts diff --git a/deploy.json b/deploy.json index 6d8c91a9..71bd6b76 100644 --- a/deploy.json +++ b/deploy.json @@ -125,7 +125,43 @@ { "id": "mdtodo", "repo": "https://github.com/pikasTech/unidesk", - "commitId": "595de3d320b73ec006794440b32db48b3ad14d2b" + "commitId": "595de3d320b73ec006794440b32db48b3ad14d2b", + "artifact": { + "kind": "source-build", + "repository": "unidesk/mdtodo", + "tag": "commitId" + }, + "consumer": { + "kind": "d601-k3s-managed", + "dev": { + "enabled": true + }, + "prod": { + "enabled": false + }, + "supportLevel": "reviewed", + "targetRef": "origin/master:deploy.json#environments.dev.services.mdtodo", + "noRuntimeSourceBuild": true, + "target": { + "namespace": "unidesk-dev", + "deployment": "mdtodo-dev", + "service": "mdtodo-dev", + "containerName": "mdtodo", + "stableImage": "unidesk-mdtodo:dev", + "manifestRepoPath": "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-mdtodo.k8s.yaml" + } + }, + "runtime": { + "containerPort": 4267, + "healthPath": "/health", + "memory": { + "request": "96Mi", + "limit": "512Mi" + }, + "health": { + "deployMetadataRequired": true + } + } }, { "id": "claudeqq", diff --git a/docs/reference/cicd-standardization.md b/docs/reference/cicd-standardization.md index 61ec35c6..53574512 100644 --- a/docs/reference/cicd-standardization.md +++ b/docs/reference/cicd-standardization.md @@ -33,15 +33,17 @@ Phase one extends the desired-state model in this order: 1. Keep existing `deploy.json` `id`, `repo` and `commitId` as the deployed source/version authority. All environment-ref deploy commands continue reading `origin/master:deploy.json`. 2. Add optional deploy-owned artifact identity fields after the static drift guard is green: `artifact.kind`, `artifact.repository`, `artifact.tag`, optional `artifact.digestRef`, and `artifact.upstreamDigestRef` for upstream-image services. For source-build services, `artifact.tag` must equal the selected commit unless a future schema explicitly records a digest-only release. 3. Add consumer intent fields: `consumer.kind`, `consumer.dev.enabled`, `consumer.prod.enabled`, `consumer.supportLevel`, `consumer.targetRef` and `consumer.noRuntimeSourceBuild`. These describe whether the service is `main-server-compose`, `d601-k3s-managed`, `d601-direct-compose`, `upstream-digest`, `dry-run-only` or `unsupported`; low-level object names still come from config/manifests until renderers exist. -4. Add deployment-time metadata that must be common across CI, dev CD and prod CD: deploy env prefix, health deploy-metadata requirement, required runtime secret key names, rollback policy, and resource profile identifiers. The planned first keys are `health.deployMetadataRequired` and `runtime.requiredSecretKeys`. Secret values, credentials, volumes and host paths do not move into `deploy.json`. +4. Add deployment-time metadata that must be common across CI, dev CD and prod CD: deploy env prefix, health deploy-metadata requirement, required runtime secret key names, rollback policy, service port, health path, and resource profile identifiers. The planned first keys are `runtime.containerPort`, `runtime.healthPath`, `runtime.memory.request`, `runtime.memory.limit`, `health.deployMetadataRequired` and `runtime.requiredSecretKeys`. Secret values, credentials, volumes and host paths do not move into `deploy.json`. 5. Teach CI producer dry-run, deploy plan and artifact-registry dry-run to render from `deploy.json` first and compare mirrored `CI.json`/`config.json`/manifest values as derived copies. A mismatch is drift, not an alternate source of truth. +Phase two starts with the low-risk `dev/mdtodo` artifact consumer. Its `deploy.json` entry now owns the source-build artifact repository/tag policy, D601 k3s consumer target, stable image, manifest reference, service port, health path and memory request/limit. `deploy plan --env dev --service mdtodo` and `deploy apply --env dev --service mdtodo --dry-run` must read those fields from `deploy.json`; the k8s manifest and artifact-registry executor constants are derived mirrors checked by a structured `deploy-json-drift` preflight. + Fields that stay outside `deploy.json` during phase one: - `CI.json`: producer pipeline name, source root, Dockerfile path and success summary shape. Once artifact identity is in `deploy.json`, `CI.json.artifacts[].image.repository` becomes a compatibility mirror checked for drift. -- `config.json`: provider id, proxy route and port policy, backend health path, Compose file/service/container names, development SSH/worktree details, and secret source locations. -- Compose and Kubernetes manifests: concrete container specs, volumes, PVCs, security context, rollout strategy and raw resource requests/limits until a renderer owns those files. -- Artifact-registry executor code: low-level pull/import/retag/recreate commands, registry probes and platform-specific verification scripts. The executor must consume the normalized deploy plan instead of hardcoding release intent. +- `config.json`: provider id, proxy route policy, Compose file/service/container names for services not yet migrated, development SSH/worktree details, and secret source locations. For `dev/mdtodo`, port/health target values in dry-run are deploy-owned and config is no longer an alternate source. +- Compose and Kubernetes manifests: volumes, PVCs, security context and rollout strategy remain concrete manifests. For `dev/mdtodo`, container port and memory request/limit are deploy-owned values and manifest copies are drift-checked mirrors until a renderer owns the file. +- Artifact-registry executor code: low-level pull/import/retag/recreate commands, registry probes and platform-specific verification scripts. For `dev/mdtodo`, executor dry-run consumes the deploy-owned artifact/consumer/runtime contract; any hardcoded mismatch returns `deploy-json-drift`. The drift contract is: @@ -53,6 +55,8 @@ The drift contract is: Lightweight evidence for this contract is `bun scripts/issue-60-cicd-drift-contract-test.ts`. The test parses local JSON/docs only; it does not publish images, deploy services, restart containers, run Playwright or run full e2e. +Focused second-stage evidence is `bun scripts/issue-60-deploy-json-executor-preflight-contract-test.ts`. It runs only dry-run/contract paths, verifies the `dev/mdtodo` executor reads image, target, port and memory from `deploy.json`, and simulates drift to assert field-level expected/actual error output. + ## Artifact Catalog Root `CI.json` is the CI producer catalog. It is not a deployment manifest. diff --git a/scripts/issue-60-cicd-drift-contract-test.ts b/scripts/issue-60-cicd-drift-contract-test.ts index a179c56b..85f021b7 100644 --- a/scripts/issue-60-cicd-drift-contract-test.ts +++ b/scripts/issue-60-cicd-drift-contract-test.ts @@ -49,11 +49,18 @@ const plannedDeployJsonFields = [ "consumer.dev.enabled", "consumer.prod.enabled", "consumer.supportLevel", + "consumer.targetRef", "consumer.noRuntimeSourceBuild", + "runtime.containerPort", + "runtime.healthPath", + "runtime.memory.request", + "runtime.memory.limit", "health.deployMetadataRequired", "runtime.requiredSecretKeys", ]; +const phaseTwoExecutorContractServices = new Set(["dev/mdtodo"]); + function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); } @@ -107,13 +114,16 @@ function assertDeployJsonReleaseIntent(deploy: JsonRecord): Array<{ environment: const services: Array<{ environment: Environment; serviceId: string; repo: string; commitId: string }> = []; for (const environment of ["dev", "prod"] as const) { for (const service of deployServicesByEnvironment(deploy, environment)) { - const keys = Object.keys(service).sort(); - assertCondition(keys.join(",") === "commitId,id,repo", "phase-one deploy.json service entries must remain only id/repo/commitId until schema migration lands", { environment, service, keys }); const serviceId = stringField(service.id, `${environment} service id`); + const key = serviceKey(environment, serviceId); + const keys = Object.keys(service).sort(); + const allowedKeys = phaseTwoExecutorContractServices.has(key) + ? ["artifact", "commitId", "consumer", "id", "repo", "runtime"] + : ["commitId", "id", "repo"]; + assertCondition(keys.join(",") === allowedKeys.join(","), "deploy.json service entries must stay in the reviewed phase-one/phase-two schema set", { environment, service, keys, allowedKeys }); const repo = stringField(service.repo, `${environment}/${serviceId}.repo`); const commitId = stringField(service.commitId, `${environment}/${serviceId}.commitId`).toLowerCase(); assertCondition(isFullSha(commitId), "deploy.json phase-one commit pins must be full 40-character SHAs", { environment, serviceId, commitId }); - const key = serviceKey(environment, serviceId); assertCondition(!seen.has(key), "deploy.json service must not be duplicated within an environment", { key }); seen.add(key); services.push({ environment, serviceId, repo, commitId }); @@ -203,6 +213,9 @@ function assertDocsContract(): void { const docs = readFileSync(rootPath("docs/reference/cicd-standardization.md"), "utf8"); for (const phrase of [ "Phase-One Deploy.json Consolidation Contract", + "dev/mdtodo", + "deploy-json-drift", + "bun scripts/issue-60-deploy-json-executor-preflight-contract-test.ts", "Current duplicated configuration surfaces", "Fields that stay outside `deploy.json` during phase one", "The drift contract is:", diff --git a/scripts/issue-60-deploy-json-executor-preflight-contract-test.ts b/scripts/issue-60-deploy-json-executor-preflight-contract-test.ts new file mode 100644 index 00000000..24f08cdf --- /dev/null +++ b/scripts/issue-60-deploy-json-executor-preflight-contract-test.ts @@ -0,0 +1,127 @@ +import { readFileSync } from "node:fs"; +import { + compareDeployJsonExecutorMirrors, + deployJsonDriftResult, + deployJsonSourceOfTruth, + encodeDeployJsonServiceContract, + k3sManifestExecutorMirror, + parseDeployJsonServiceContract, + type DeployJsonServiceContract, + type DeployJsonExecutorMirror, +} from "./src/deploy-json-contract"; +import { rootPath } from "./src/config"; +import { runArtifactRegistryCommand } from "./src/artifact-registry"; + +type JsonRecord = Record; + +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; +} + +function deployService(environment: "dev" | "prod", serviceId: string): DeployJsonServiceContract { + const deploy = asRecord(JSON.parse(readFileSync(rootPath("deploy.json"), "utf8")) as unknown, "deploy.json"); + const environments = asRecord(deploy.environments, "deploy.json.environments"); + const env = asRecord(environments[environment], `deploy.json.environments.${environment}`); + const services = asArray(env.services, `deploy.json.environments.${environment}.services`); + const raw = services.find((item) => asRecord(item, `${environment} service`).id === serviceId); + assertCondition(raw !== undefined, `deploy.json must contain ${environment}/${serviceId}`); + return parseDeployJsonServiceContract(raw, `deploy.json.environments.${environment}.services.${serviceId}`); +} + +const service = deployService("dev", "mdtodo"); +const artifact = asRecord(service.artifact, "mdtodo artifact"); +const consumer = asRecord(service.consumer, "mdtodo consumer"); +const targetContract = asRecord(consumer.target, "mdtodo consumer target"); +const runtime = asRecord(service.runtime, "mdtodo runtime"); +const memory = asRecord(runtime.memory, "mdtodo runtime memory"); +const commit = String(service.commitId); +const sourceOfTruth = deployJsonSourceOfTruth(service, "dev"); +const deploySource = readFileSync(rootPath("scripts/src/deploy.ts"), "utf8"); +assertCondition(deploySource.includes('"--deploy-json-service", encodeDeployJsonServiceContract(service)'), "deploy executor must pass deploy.json service contract into artifact-registry", {}); + +const applyPlan = asRecord(await runArtifactRegistryCommand([ + "deploy-service", + "--env", + "dev", + "--service", + "mdtodo", + "--commit", + commit, + "--source-repo", + service.repo, + "--deploy-ref", + "origin/master:deploy.json#environments.dev.services.mdtodo", + "--deploy-json-service", + encodeDeployJsonServiceContract(service), + "--dry-run", +]), "artifact-registry dry-run result"); +const applyTarget = asRecord(applyPlan.target, "artifact-registry target"); +const applyRegistry = asRecord(applyPlan.registry, "artifact-registry registry"); +const applyRuntime = asRecord(applyPlan.runtime, "artifact-registry runtime"); +const applyRuntimeMemory = asRecord(applyRuntime.memory, "artifact-registry runtime memory"); +const applyDriftCheck = asRecord(applyPlan.driftCheck, "artifact-registry driftCheck"); +assertCondition(applyPlan.sourceOfTruth !== undefined, "artifact-registry dry-run should expose deploy.json sourceOfTruth", applyPlan); +assertCondition(JSON.stringify(applyPlan.sourceOfTruth) === JSON.stringify(sourceOfTruth), "artifact-registry sourceOfTruth must enumerate deploy.json fields", applyPlan.sourceOfTruth); +assertCondition(applyDriftCheck.ok === true, "artifact-registry dry-run must report a passing drift preflight", applyDriftCheck); +assertCondition(applyRegistry.repository === artifact.repository, "artifact-registry dry-run repository must come from deploy.json", applyRegistry); +assertCondition(applyRegistry.tag === commit, "artifact-registry dry-run tag must be deploy.json commitId", applyRegistry); +assertCondition(applyRegistry.imageRef === `127.0.0.1:5000/${artifact.repository}:${commit}`, "artifact-registry imageRef must be deploy.json artifact repository + commit", applyRegistry); +assertCondition(applyTarget.namespace === targetContract.namespace, "artifact-registry dry-run namespace must come from deploy.json", applyTarget); +assertCondition(applyTarget.deployment === targetContract.deployment, "artifact-registry dry-run deployment must come from deploy.json", applyTarget); +assertCondition(applyTarget.service === targetContract.service, "artifact-registry dry-run service must come from deploy.json", applyTarget); +assertCondition(asArray(applyTarget.deployments, "artifact-registry deployments").some((item) => asRecord(item, "deployment").name === targetContract.deployment), "artifact-registry deployments must come from deploy.json target", applyTarget); +assertCondition(applyTarget.stableImage === targetContract.stableImage, "artifact-registry stableImage must come from deploy.json", applyTarget); +assertCondition(applyTarget.runtimeImage === `unidesk-mdtodo:${commit}`, "artifact-registry runtimeImage must derive from deploy.json stableImage + commit", applyTarget); +assertCondition(applyTarget.manifestRepoPath === targetContract.manifestRepoPath, "artifact-registry manifest path must come from deploy.json", applyTarget); +assertCondition(applyRuntime.sourceOfTruth === "deploy.json", "artifact-registry runtime source must be deploy.json", applyRuntime); +assertCondition(applyRuntime.containerPort === runtime.containerPort, "artifact-registry runtime port must come from deploy.json", applyRuntime); +assertCondition(applyRuntime.healthPath === runtime.healthPath, "artifact-registry runtime healthPath must come from deploy.json", applyRuntime); +assertCondition(applyRuntimeMemory.request === memory.request && applyRuntimeMemory.limit === memory.limit, "artifact-registry memory must come from deploy.json", applyRuntimeMemory); + +const manifestMirror = k3sManifestExecutorMirror(service); +assertCondition(manifestMirror !== null, "mdtodo deploy.json contract should locate a k8s manifest mirror"); +const cleanDrifts = compareDeployJsonExecutorMirrors(service, "dev", [manifestMirror!]); +assertCondition(cleanDrifts.length === 0, "current k8s manifest mirror should match deploy.json contract", cleanDrifts); + +const driftMirror: DeployJsonExecutorMirror = { + ...manifestMirror!, + runtime: { + ...manifestMirror!.runtime, + containerPort: 4268, + memory: { + ...manifestMirror!.runtime?.memory, + limit: "384Mi", + }, + }, +}; +const drift = compareDeployJsonExecutorMirrors(service, "dev", [driftMirror]); +const driftResult = deployJsonDriftResult(service, "dev", drift); +const driftPayload = asRecord(driftResult.drift, "drift result payload"); +const driftItems = asArray(driftPayload.items, "drift result items").map((item) => asRecord(item, "drift item")); +assertCondition(driftResult.ok === false, "drift result must be non-ok", driftResult); +assertCondition(driftResult.error === "deploy-json-drift", "drift result should use structured deploy-json-drift error", driftResult); +assertCondition(driftItems.some((item) => item.field === "runtime.containerPort" && item.expected === 4267 && item.actual === 4268), "drift result should report port mismatch", driftItems); +assertCondition(driftItems.some((item) => item.field === "runtime.memory.limit" && item.expected === "512Mi" && item.actual === "384Mi"), "drift result should report memory mismatch", driftItems); + +process.stdout.write(`${JSON.stringify({ + ok: true, + checks: [ + "deploy executor passes the deploy.json service contract to artifact-registry dry-run", + "artifact-registry dry-run reads mdtodo image, target, port and memory fields from deploy.json", + "k8s manifest target/port/memory are treated as derived mirrors and checked for drift", + "drift preflight returns structured deploy-json-drift with field-level expected/actual values", + ], + serviceId: service.id, + commit, + sourceOfTruth, +}, null, 2)}\n`); diff --git a/scripts/src/artifact-registry.ts b/scripts/src/artifact-registry.ts index 4d9fe4b0..313cc3e5 100644 --- a/scripts/src/artifact-registry.ts +++ b/scripts/src/artifact-registry.ts @@ -904,6 +904,9 @@ export function parseArtifactRegistryOptions(args: string[]): ArtifactRegistryOp } else if (arg === "--deploy-ref") { options.deployRef = requireValue(args, index, arg); index += 1; + } else if (arg === "--deploy-json-service") { + options.deployJsonService = parseDeployJsonServiceContractBase64(requireValue(args, index, arg)); + index += 1; } else if (arg === "--env" || arg === "--environment") { options.environment = environmentValue(requireValue(args, index, arg), arg); index += 1; @@ -1365,7 +1368,7 @@ function artifactConsumerLiveBlock(spec: ArtifactConsumerSpec, options: Artifact } function artifactImageRef(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, commit: string): string { - return `127.0.0.1:${options.port}/${spec.registryRepository}:${commit}`; + return `127.0.0.1:${options.port}/${(options.dryRun ? options.deployJsonService?.artifact?.repository : undefined) ?? spec.registryRepository}:${commit}`; } function sourceRepoFor(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec): string { @@ -1374,7 +1377,68 @@ function sourceRepoFor(options: ArtifactRegistryOptions, spec: ArtifactConsumerS function deployRefFor(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec): string { const target = artifactConsumerTarget(spec, options.environment); - return options.deployRef ?? target?.deployRef ?? `deploy.json#environments.${options.environment ?? "prod"}.services.${spec.serviceId}`; + return options.deployRef ?? (options.dryRun ? options.deployJsonService?.consumer?.targetRef : undefined) ?? target?.deployRef ?? `deploy.json#environments.${options.environment ?? "prod"}.services.${spec.serviceId}`; +} + +function deployJsonServiceForOptions(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, environment: ArtifactDeployEnvironment): DeployJsonServiceContract | null { + if (!options.dryRun) return null; + if (options.deployJsonService !== null) return options.deployJsonService; + if (environment === "dev" && spec.serviceId === "mdtodo") { + return readDeployJsonServiceContractFromFile(environment, spec.serviceId); + } + return null; +} + +function targetImageFor(options: ArtifactRegistryOptions, target: ArtifactConsumerTarget): string { + return (options.dryRun ? options.deployJsonService?.consumer?.target.stableImage : undefined) ?? target.targetImage; +} + +function targetCommitImageFor(options: ArtifactRegistryOptions, target: ArtifactConsumerTarget, commit: string): string { + const stableImage = options.dryRun ? options.deployJsonService?.consumer?.target.stableImage : undefined; + return stableImage === undefined ? target.targetCommitImage(commit) : deployJsonCommitImage(stableImage, commit); +} + +function registryRepositoryFor(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec): string { + return (options.dryRun ? options.deployJsonService?.artifact?.repository : undefined) ?? spec.registryRepository; +} + +function artifactRegistryDeployJsonMirrors( + options: ArtifactRegistryOptions, + spec: ArtifactConsumerSpec, + target: ArtifactConsumerTarget, +): DeployJsonExecutorMirror[] { + const mirrors: DeployJsonExecutorMirror[] = [ + { + surface: "artifact-registry-executor", + artifact: { + kind: "source-build", + repository: spec.registryRepository, + tag: "commitId", + }, + consumer: { + kind: spec.kind === "d601-k3s" ? "d601-k3s-managed" : spec.kind, + noRuntimeSourceBuild: true, + target: target.k3s === undefined ? undefined : { + namespace: target.k3s.namespace, + deployment: target.k3s.deploymentName, + service: target.k3s.serviceName, + containerName: target.k3s.containerName, + stableImage: target.targetImage, + manifestRepoPath: target.k3s.manifestRepoPath, + }, + }, + runtime: target.k3s === undefined ? undefined : { + containerPort: target.k3s.servicePort, + servicePort: target.k3s.servicePort, + healthPath: target.k3s.healthPath, + }, + }, + ]; + if (options.deployJsonService !== null) { + const manifestMirror = k3sManifestExecutorMirror(options.deployJsonService); + if (manifestMirror !== null) mirrors.push(manifestMirror); + } + return mirrors; } function runRemoteScript(options: ArtifactRegistryOptions, script: string, timeoutMs = options.timeoutMs, runtime: ArtifactRegistryCommandRuntime = {}): CommandResult { @@ -2469,16 +2533,24 @@ function legacyDeployBackendCoreResult(options: ArtifactRegistryOptions): Record function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget, commit: string): Record { const environment = options.environment ?? "prod"; + const deployJsonService = deployJsonServiceForOptions(options, spec, environment); + const effectiveOptions = deployJsonService === options.deployJsonService ? options : { ...options, deployJsonService }; + const drifts = deployJsonService !== null && hasDeployJsonExecutorContract(deployJsonService) + ? compareDeployJsonExecutorMirrors(deployJsonService, environment, artifactRegistryDeployJsonMirrors(effectiveOptions, spec, target)) + : []; + if (drifts.length > 0) return deployJsonDriftResult(deployJsonService, environment, drifts); const verificationBlocked = spec.runtimeVerification === "blocked"; const livePolicy = environment === "prod" ? spec.prodLiveApply : "enabled"; - const sourceImage = artifactImageRef(options, spec, commit); - const registryEndpoint = `http://127.0.0.1:${options.port}`; + const sourceImage = artifactImageRef(effectiveOptions, spec, commit); + const registryEndpoint = `http://127.0.0.1:${effectiveOptions.port}`; const config = readConfig(); + const contractTarget = deployJsonService?.consumer?.target; + const contractRuntime = deployJsonService?.runtime; const k3sDeployments = target.k3s === undefined ? [] : [ - { name: target.k3s.deploymentName, containerName: target.k3s.containerName }, - ...(target.k3s.extraDeployments ?? []).map((deployment) => typeof deployment === "string" + { name: contractTarget?.deployment ?? target.k3s.deploymentName, containerName: contractTarget?.containerName ?? target.k3s.containerName }, + ...(contractTarget === undefined ? (target.k3s.extraDeployments ?? []) : []).map((deployment) => typeof deployment === "string" ? { name: deployment, containerName: target.k3s!.containerName } : { name: deployment.name, containerName: deployment.containerName ?? target.k3s!.containerName }), ]; @@ -2489,20 +2561,20 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti mutation: false, error: verificationBlocked ? "runtime-verification-blocked" : undefined, environment, - providerId: options.providerId, + providerId: effectiveOptions.providerId, serviceId: spec.serviceId, commit, - sourceRepo: sourceRepoFor(options, spec), - deployRef: deployRefFor(options, spec), + sourceRepo: sourceRepoFor(effectiveOptions, spec), + deployRef: deployRefFor(effectiveOptions, spec), sourceImage, source: { - repo: sourceRepoFor(options, spec), + repo: sourceRepoFor(effectiveOptions, spec), commit, dockerfile: spec.dockerfile, }, registry: { endpoint: registryEndpoint, - repository: spec.registryRepository, + repository: registryRepositoryFor(effectiveOptions, spec), tag: commit, imageRef: sourceImage, digest: null, @@ -2518,13 +2590,13 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti }, requiredLabels: { "unidesk.ai/service-id": spec.serviceId, - "unidesk.ai/source-repo": sourceRepoFor(options, spec), + "unidesk.ai/source-repo": sourceRepoFor(effectiveOptions, spec), "unidesk.ai/source-commit": commit, "unidesk.ai/dockerfile": spec.dockerfile, }, registryProbe: { method: "HEAD", - url: `${registryEndpoint}/v2/${spec.registryRepository}/manifests/${commit}`, + url: `${registryEndpoint}/v2/${registryRepositoryFor(effectiveOptions, spec)}/manifests/${commit}`, digestHeader: "Docker-Content-Digest", }, boundary: `${environment} CD is artifact-consumer only: verify commit-pinned registry image, pull/import, deploy, then verify live commit/image/health; it never builds source on the runtime target`, @@ -2533,6 +2605,11 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti allowed: !verificationBlocked && (environment !== "prod" || spec.prodLiveApply === "enabled"), reason: spec.runtimeVerificationBlockReason ?? (environment === "prod" ? spec.prodLiveBlockReason ?? null : null), }, + sourceOfTruth: deployJsonService !== null && hasDeployJsonExecutorContract(deployJsonService) ? deployJsonSourceOfTruth(deployJsonService, environment) : undefined, + driftCheck: deployJsonService !== null && hasDeployJsonExecutorContract(deployJsonService) ? { + ok: true, + mirrors: ["artifact-registry-executor", ...(deployJsonService.consumer?.kind === "d601-k3s-managed" ? ["k8s-manifest"] : [])], + } : undefined, }; if (spec.kind === "compose" || spec.kind === "d601-compose") { if (target.compose === undefined) throw new Error(`${spec.serviceId} missing compose artifact consumer config`); @@ -2598,15 +2675,22 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti ...common, target: { kind: "d601-k3s", - namespace: target.k3s?.namespace, - deployment: target.k3s?.deploymentName, + namespace: contractTarget?.namespace ?? target.k3s?.namespace, + deployment: contractTarget?.deployment ?? target.k3s?.deploymentName, deployments: k3sDeployments, - service: target.k3s?.serviceName, - stableImage: target.targetImage, - runtimeImage: target.targetCommitImage(commit), - manifestRepoPath: target.k3s?.manifestRepoPath, + service: contractTarget?.service ?? target.k3s?.serviceName, + stableImage: targetImageFor(effectiveOptions, target), + runtimeImage: targetCommitImageFor(effectiveOptions, target, commit), + manifestRepoPath: contractTarget?.manifestRepoPath ?? target.k3s?.manifestRepoPath, deployCommandShape: "kubectl set image + set env + annotate + rollout status", }, + runtime: contractRuntime === undefined ? undefined : { + sourceOfTruth: "deploy.json", + containerPort: contractRuntime.containerPort, + healthPath: contractRuntime.healthPath, + memory: contractRuntime.memory, + health: contractRuntime.health, + }, validation: [ "D601 registry /v2 manifest exists for the commit tag before mutation", "D601 Docker-pulled image labels match service id, source repo, source commit, and Dockerfile", diff --git a/scripts/src/deploy-json-contract.ts b/scripts/src/deploy-json-contract.ts new file mode 100644 index 00000000..48479845 --- /dev/null +++ b/scripts/src/deploy-json-contract.ts @@ -0,0 +1,407 @@ +import { readFileSync } from "node:fs"; +import { rootPath } from "./config"; + +export type DeployJsonEnvironment = "dev" | "prod"; + +export interface DeployJsonArtifactContract { + kind: "source-build"; + repository: string; + tag: "commitId"; +} + +export interface DeployJsonConsumerTargetContract { + namespace: string; + deployment: string; + service: string; + containerName: string; + stableImage: string; + manifestRepoPath: string; +} + +export interface DeployJsonConsumerContract { + kind: "d601-k3s-managed"; + dev?: { enabled: boolean }; + prod?: { enabled: boolean }; + supportLevel: "reviewed"; + targetRef: string; + noRuntimeSourceBuild: boolean; + target: DeployJsonConsumerTargetContract; +} + +export interface DeployJsonRuntimeContract { + containerPort: number; + healthPath: string; + memory: { + request: string; + limit: string; + }; + health?: { + deployMetadataRequired: boolean; + }; +} + +export interface DeployJsonServiceContract { + id: string; + repo: string; + commitId: string; + artifact?: DeployJsonArtifactContract; + consumer?: DeployJsonConsumerContract; + runtime?: DeployJsonRuntimeContract; +} + +export interface DeployJsonDriftItem { + surface: string; + path?: string; + field: string; + expected: unknown; + actual: unknown; +} + +export interface DeployJsonExecutorMirror { + surface: string; + path?: string; + artifact?: { + kind?: string; + repository?: string; + tag?: string; + }; + consumer?: { + kind?: string; + noRuntimeSourceBuild?: boolean; + target?: Partial; + }; + runtime?: { + containerPort?: number; + servicePort?: number; + healthPath?: string; + memory?: { + request?: string; + limit?: string; + }; + }; +} + +function asRecord(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value as Record; +} + +function optionalRecord(value: unknown, path: string): Record | undefined { + if (value === undefined) return undefined; + return asRecord(value, path); +} + +function stringField(record: Record, key: string, path: string): string { + const value = record[key]; + if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`); + return value; +} + +function numberField(record: Record, key: string, path: string): number { + const value = record[key]; + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new Error(`${path}.${key} must be a positive integer`); + return value; +} + +function booleanField(record: Record, key: string, path: string): boolean { + const value = record[key]; + if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`); + return value; +} + +function assertKnownKeys(record: Record, allowed: string[], path: string): void { + const unknown = Object.keys(record).filter((key) => !allowed.includes(key)); + if (unknown.length > 0) throw new Error(`${path} contains unsupported deploy.json contract key(s): ${unknown.join(", ")}`); +} + +function parseArtifactContract(value: unknown, path: string): DeployJsonArtifactContract | undefined { + const artifact = optionalRecord(value, path); + if (artifact === undefined) return undefined; + assertKnownKeys(artifact, ["kind", "repository", "tag"], path); + const kind = stringField(artifact, "kind", path); + if (kind !== "source-build") throw new Error(`${path}.kind must be source-build`); + const tag = stringField(artifact, "tag", path); + if (tag !== "commitId") throw new Error(`${path}.tag must be commitId`); + return { + kind, + repository: stringField(artifact, "repository", path), + tag, + }; +} + +function parseEnabled(value: unknown, path: string): { enabled: boolean } | undefined { + const record = optionalRecord(value, path); + if (record === undefined) return undefined; + assertKnownKeys(record, ["enabled"], path); + return { enabled: booleanField(record, "enabled", path) }; +} + +function parseConsumerTarget(value: unknown, path: string): DeployJsonConsumerTargetContract { + const target = asRecord(value, path); + assertKnownKeys(target, ["namespace", "deployment", "service", "containerName", "stableImage", "manifestRepoPath"], path); + return { + namespace: stringField(target, "namespace", path), + deployment: stringField(target, "deployment", path), + service: stringField(target, "service", path), + containerName: stringField(target, "containerName", path), + stableImage: stringField(target, "stableImage", path), + manifestRepoPath: stringField(target, "manifestRepoPath", path), + }; +} + +function parseConsumerContract(value: unknown, path: string): DeployJsonConsumerContract | undefined { + const consumer = optionalRecord(value, path); + if (consumer === undefined) return undefined; + assertKnownKeys(consumer, ["kind", "dev", "prod", "supportLevel", "targetRef", "noRuntimeSourceBuild", "target"], path); + const kind = stringField(consumer, "kind", path); + if (kind !== "d601-k3s-managed") throw new Error(`${path}.kind must be d601-k3s-managed`); + const supportLevel = stringField(consumer, "supportLevel", path); + if (supportLevel !== "reviewed") throw new Error(`${path}.supportLevel must be reviewed`); + return { + kind, + dev: parseEnabled(consumer.dev, `${path}.dev`), + prod: parseEnabled(consumer.prod, `${path}.prod`), + supportLevel, + targetRef: stringField(consumer, "targetRef", path), + noRuntimeSourceBuild: booleanField(consumer, "noRuntimeSourceBuild", path), + target: parseConsumerTarget(consumer.target, `${path}.target`), + }; +} + +function parseRuntimeContract(value: unknown, path: string): DeployJsonRuntimeContract | undefined { + const runtime = optionalRecord(value, path); + if (runtime === undefined) return undefined; + assertKnownKeys(runtime, ["containerPort", "healthPath", "memory", "health"], path); + const memory = asRecord(runtime.memory, `${path}.memory`); + assertKnownKeys(memory, ["request", "limit"], `${path}.memory`); + const health = optionalRecord(runtime.health, `${path}.health`); + if (health !== undefined) assertKnownKeys(health, ["deployMetadataRequired"], `${path}.health`); + return { + containerPort: numberField(runtime, "containerPort", path), + healthPath: stringField(runtime, "healthPath", path), + memory: { + request: stringField(memory, "request", `${path}.memory`), + limit: stringField(memory, "limit", `${path}.memory`), + }, + health: health === undefined ? undefined : { + deployMetadataRequired: booleanField(health, "deployMetadataRequired", `${path}.health`), + }, + }; +} + +export function parseDeployJsonServiceContract(value: unknown, path: string): DeployJsonServiceContract { + const service = asRecord(value, path); + assertKnownKeys(service, ["id", "repo", "commitId", "artifact", "consumer", "runtime"], path); + const id = stringField(service, "id", path); + const repo = stringField(service, "repo", path); + const commitId = stringField(service, "commitId", path).toLowerCase(); + if (!/^[0-9a-f]{7,40}$/iu.test(commitId)) throw new Error(`${path}.commitId must be a 7-40 char git SHA`); + return { + id, + repo, + commitId, + artifact: parseArtifactContract(service.artifact, `${path}.artifact`), + consumer: parseConsumerContract(service.consumer, `${path}.consumer`), + runtime: parseRuntimeContract(service.runtime, `${path}.runtime`), + }; +} + +export function parseDeployJsonServiceContractBase64(raw: string): DeployJsonServiceContract { + const text = Buffer.from(raw, "base64").toString("utf8"); + return parseDeployJsonServiceContract(JSON.parse(text) as unknown, "deploy service contract"); +} + +export function encodeDeployJsonServiceContract(service: DeployJsonServiceContract): string { + return Buffer.from(JSON.stringify(service), "utf8").toString("base64"); +} + +export function hasDeployJsonExecutorContract(service: DeployJsonServiceContract): boolean { + return service.artifact !== undefined || service.consumer !== undefined || service.runtime !== undefined; +} + +export function deployJsonCommitImage(stableImage: string, commit: string): string { + const lastSlash = stableImage.lastIndexOf("/"); + const tagIndex = stableImage.indexOf(":", lastSlash + 1); + if (tagIndex === -1) return `${stableImage}:${commit}`; + return `${stableImage.slice(0, tagIndex)}:${commit}`; +} + +export function deployJsonSourceOfTruth(service: DeployJsonServiceContract, environment: DeployJsonEnvironment): Record { + return { + source: "deploy.json", + environment, + serviceId: service.id, + servicePath: `environments.${environment}.services.${service.id}`, + ownedFields: [ + ...(service.artifact === undefined ? [] : ["artifact.kind", "artifact.repository", "artifact.tag"]), + ...(service.consumer === undefined ? [] : [ + "consumer.kind", + `consumer.${environment}.enabled`, + "consumer.supportLevel", + "consumer.targetRef", + "consumer.noRuntimeSourceBuild", + "consumer.target.namespace", + "consumer.target.deployment", + "consumer.target.service", + "consumer.target.containerName", + "consumer.target.stableImage", + "consumer.target.manifestRepoPath", + ]), + ...(service.runtime === undefined ? [] : [ + "runtime.containerPort", + "runtime.healthPath", + "runtime.memory.request", + "runtime.memory.limit", + "runtime.health.deployMetadataRequired", + ]), + ], + }; +} + +function pushDrift(items: DeployJsonDriftItem[], mirror: DeployJsonExecutorMirror, field: string, expected: unknown, actual: unknown): void { + if (actual === undefined || expected === actual) return; + items.push({ surface: mirror.surface, path: mirror.path, field, expected, actual }); +} + +export function compareDeployJsonExecutorMirrors( + service: DeployJsonServiceContract, + _environment: DeployJsonEnvironment, + mirrors: DeployJsonExecutorMirror[], +): DeployJsonDriftItem[] { + const items: DeployJsonDriftItem[] = []; + for (const mirror of mirrors) { + if (service.artifact !== undefined && mirror.artifact !== undefined) { + pushDrift(items, mirror, "artifact.kind", service.artifact.kind, mirror.artifact.kind); + pushDrift(items, mirror, "artifact.repository", service.artifact.repository, mirror.artifact.repository); + pushDrift(items, mirror, "artifact.tag", service.artifact.tag, mirror.artifact.tag); + } + if (service.consumer !== undefined && mirror.consumer !== undefined) { + pushDrift(items, mirror, "consumer.kind", service.consumer.kind, mirror.consumer.kind); + pushDrift(items, mirror, "consumer.noRuntimeSourceBuild", service.consumer.noRuntimeSourceBuild, mirror.consumer.noRuntimeSourceBuild); + const expectedTarget = service.consumer.target; + const actualTarget = mirror.consumer.target; + if (actualTarget !== undefined) { + pushDrift(items, mirror, "consumer.target.namespace", expectedTarget.namespace, actualTarget.namespace); + pushDrift(items, mirror, "consumer.target.deployment", expectedTarget.deployment, actualTarget.deployment); + pushDrift(items, mirror, "consumer.target.service", expectedTarget.service, actualTarget.service); + pushDrift(items, mirror, "consumer.target.containerName", expectedTarget.containerName, actualTarget.containerName); + pushDrift(items, mirror, "consumer.target.stableImage", expectedTarget.stableImage, actualTarget.stableImage); + pushDrift(items, mirror, "consumer.target.manifestRepoPath", expectedTarget.manifestRepoPath, actualTarget.manifestRepoPath); + } + } + if (service.runtime !== undefined && mirror.runtime !== undefined) { + pushDrift(items, mirror, "runtime.containerPort", service.runtime.containerPort, mirror.runtime.containerPort); + pushDrift(items, mirror, "runtime.servicePort", service.runtime.containerPort, mirror.runtime.servicePort); + pushDrift(items, mirror, "runtime.healthPath", service.runtime.healthPath, mirror.runtime.healthPath); + pushDrift(items, mirror, "runtime.memory.request", service.runtime.memory.request, mirror.runtime.memory?.request); + pushDrift(items, mirror, "runtime.memory.limit", service.runtime.memory.limit, mirror.runtime.memory?.limit); + } + } + return items; +} + +function yamlDocuments(text: string): string[] { + return text.split(/^---\s*$/mu).map((part) => part.trim()).filter((part) => part.length > 0); +} + +function yamlKind(documentText: string): string { + return documentText.match(/^kind:\s*("?)([^"\s#]+)\1\s*$/mu)?.[2] ?? ""; +} + +function yamlMetadataField(documentText: string, field: "name" | "namespace"): string { + const metadataIndex = documentText.search(/^metadata:\s*$/mu); + if (metadataIndex === -1) return ""; + const metadata = documentText.slice(metadataIndex); + return metadata.match(new RegExp(`^ ${field}:\\s*("?)([^"\\n#]+)\\1\\s*$`, "mu"))?.[2]?.trim() ?? ""; +} + +function yamlDocumentByKindName(documents: string[], kind: string, name: string): string { + return documents.find((documentText) => yamlKind(documentText) === kind && yamlMetadataField(documentText, "name") === name) ?? ""; +} + +function firstRegex(text: string, pattern: RegExp): string | undefined { + return text.match(pattern)?.[1]; +} + +function containerSegment(deploymentText: string, containerName: string): string { + const escaped = containerName.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const match = new RegExp(`^ - name:\\s*${escaped}\\s*$`, "mu").exec(deploymentText); + if (match === null) return ""; + const start = match.index; + const rest = deploymentText.slice(start + match[0].length); + const next = /^ - name:\s*\S+\s*$/mu.exec(rest); + return deploymentText.slice(start, next === null ? undefined : start + match[0].length + next.index); +} + +export function k3sManifestExecutorMirror(service: DeployJsonServiceContract): DeployJsonExecutorMirror | null { + const target = service.consumer?.target; + if (service.consumer?.kind !== "d601-k3s-managed" || target === undefined) return null; + const path = target.manifestRepoPath; + const manifest = readFileSync(rootPath(path), "utf8"); + const documents = yamlDocuments(manifest); + const deployment = yamlDocumentByKindName(documents, "Deployment", target.deployment); + const serviceDoc = yamlDocumentByKindName(documents, "Service", target.service); + const container = containerSegment(deployment, target.containerName); + const containerPortRaw = firstRegex(container, /^\s+containerPort:\s*(\d+)\s*$/mu); + const servicePortRaw = firstRegex(serviceDoc, /^\s+port:\s*(\d+)\s*$/mu); + return { + surface: "k8s-manifest", + path, + consumer: { + kind: "d601-k3s-managed", + noRuntimeSourceBuild: true, + target: { + namespace: yamlMetadataField(deployment, "namespace") || yamlMetadataField(serviceDoc, "namespace"), + deployment: yamlMetadataField(deployment, "name"), + service: yamlMetadataField(serviceDoc, "name"), + containerName: firstRegex(container, /^ - name:\s*([^\s#]+)\s*$/mu), + manifestRepoPath: path, + }, + }, + runtime: { + containerPort: containerPortRaw === undefined ? undefined : Number(containerPortRaw), + servicePort: servicePortRaw === undefined ? undefined : Number(servicePortRaw), + healthPath: firstRegex(container, /^\s+path:\s*([^\s#"]+)\s*$/mu), + memory: { + request: firstRegex(container, /^\s+requests:\s*$[\s\S]*?^\s+memory:\s*([^\s#"]+)\s*$/mu), + limit: firstRegex(container, /^\s+limits:\s*$[\s\S]*?^\s+memory:\s*([^\s#"]+)\s*$/mu), + }, + }, + }; +} + +export function deployJsonDriftResult( + service: DeployJsonServiceContract, + environment: DeployJsonEnvironment, + drifts: DeployJsonDriftItem[], +): Record { + return { + ok: false, + supported: false, + error: "deploy-json-drift", + serviceId: service.id, + environment, + sourceOfTruth: deployJsonSourceOfTruth(service, environment), + drift: { + ok: false, + count: drifts.length, + items: drifts, + }, + policy: "deploy.json is the only source for this executor contract; mirrored manifest, artifact-registry, CI catalog or config values must be regenerated or corrected before dry-run can proceed.", + }; +} + +export function readDeployJsonServiceContractFromFile( + environment: DeployJsonEnvironment, + serviceId: string, + filePath = rootPath("deploy.json"), +): DeployJsonServiceContract | null { + const root = asRecord(JSON.parse(readFileSync(filePath, "utf8")) as unknown, "deploy.json"); + const environments = asRecord(root.environments, "deploy.json.environments"); + const environmentRecord = asRecord(environments[environment], `deploy.json.environments.${environment}`); + const services = environmentRecord.services; + if (!Array.isArray(services)) throw new Error(`deploy.json.environments.${environment}.services must be an array`); + const index = services.findIndex((item) => asRecord(item, `deploy.json.environments.${environment}.services[]`).id === serviceId); + if (index === -1) return null; + return parseDeployJsonServiceContract(services[index], `deploy.json.environments.${environment}.services[${index}]`); +} diff --git a/scripts/src/deploy.ts b/scripts/src/deploy.ts index aa06bb42..236ba96b 100644 --- a/scripts/src/deploy.ts +++ b/scripts/src/deploy.ts @@ -9,15 +9,23 @@ import { baiduNetdiskRuntimeSecretRequirements, runtimeSecretContractFromEnvText import { startJob } from "./jobs"; import { coreInternalFetch } from "./microservices"; import { codeQueueSourceImportPreflight, codeQueueSourceSubdir } from "./code-queue-source-guard"; +import { + compareDeployJsonExecutorMirrors, + deployJsonCommitImage, + deployJsonDriftResult, + deployJsonSourceOfTruth, + encodeDeployJsonServiceContract, + hasDeployJsonExecutorContract, + k3sManifestExecutorMirror, + parseDeployJsonServiceContract, + type DeployJsonExecutorMirror, + type DeployJsonServiceContract, +} from "./deploy-json-contract"; type DeployAction = "check" | "plan" | "apply"; type DeployEnvironment = "dev" | "prod"; -interface DeployManifestService { - id: string; - repo: string; - commitId: string; -} +type DeployManifestService = DeployJsonServiceContract; interface DeployManifest { schemaVersion: 1 | 2; @@ -512,17 +520,16 @@ function parseOptions(args: string[]): DeployOptions { } function parseDeployManifestService(item: unknown, index: number, seen: Set, path: string): DeployManifestService { - const service = asRecord(item); - if (service === null) throw new Error(`deploy manifest ${path}[${index}] must be an object`); - const id = asString(service.id); - const repo = asString(service.repo); - const commitId = asString(service.commitId).toLowerCase(); + const service = parseDeployJsonServiceContract(item, `deploy manifest ${path}[${index}]`); + const id = service.id; + const repo = service.repo; + const commitId = service.commitId; if (id.length === 0) throw new Error(`deploy manifest ${path}[${index}].id must be a non-empty string`); if (repo.length === 0) throw new Error(`deploy manifest ${path}[${index}].repo must be a non-empty string`); if (!/^[0-9a-f]{7,40}$/iu.test(commitId)) throw new Error(`deploy manifest ${path}[${index}].commitId must be a 7-40 char git SHA`); if (seen.has(id)) throw new Error(`duplicate deploy manifest service id: ${id}`); seen.add(id); - return { id, repo, commitId }; + return service; } function parseDeployManifestServices(value: unknown, path: string): DeployManifestService[] { @@ -896,6 +903,11 @@ function artifactConsumerPlanKind(service: UniDeskMicroserviceConfig, environmen return "unsupported"; } +function artifactConsumerPlanKindFromDeployJson(service: DeployManifestService, fallback: ArtifactConsumerPlanKind): ArtifactConsumerPlanKind { + if (service.consumer?.kind === "d601-k3s-managed") return "d601-k3s-managed"; + return fallback; +} + function deploymentPathForEnvironmentService(service: UniDeskMicroserviceConfig, environment: DeployEnvironment): string { return deploymentPathForEnvironmentServiceId(service.id, environment); } @@ -959,6 +971,35 @@ function artifactConsumerPlanTarget(service: UniDeskMicroserviceConfig, environm return common; } +function artifactConsumerPlanTargetFromDeployJson( + service: DeployManifestService, + fallbackService: UniDeskMicroserviceConfig, + environment: DeployEnvironment, + fallbackTarget: Record | null, +): Record | null { + if (service.consumer?.kind !== "d601-k3s-managed") return fallbackTarget; + const target = service.consumer.target; + const liveBlockReason = environment === "prod" ? prodArtifactLiveApplyBlockedServiceIds.get(service.id) ?? null : null; + const dryRunBlockedReason = artifactConsumerDryRunBlockedServiceIds.get(service.id) ?? null; + return { + consumerKind: service.consumer.kind, + runtimeHost: "D601", + deploymentMode: fallbackService.deployment.mode, + noRuntimeSourceBuild: service.consumer.noRuntimeSourceBuild, + dryRunOnly: dryRunBlockedReason !== null || (environment === "prod" && prodArtifactLiveApplyBlockedServiceIds.has(service.id)), + blockedReason: dryRunBlockedReason ?? liveBlockReason, + namespace: target.namespace, + deployment: target.deployment, + service: target.service, + containerName: target.containerName, + manifest: target.manifestRepoPath, + targetImage: target.stableImage, + deployCommandShape: "registry manifest check + native k3s containerd import + Kubernetes Deployment image/env/annotation update + API service proxy health", + forbiddenActions: ["docker build", "docker compose build", "NodePort", "hostPort", "provider-gateway direct business backend"], + sourceOfTruth: deployJsonSourceOfTruth(service, environment), + }; +} + function artifactConsumerTargetImageHint(service: UniDeskMicroserviceConfig, environment: DeployEnvironment): string { if (service.id === "frontend" && environment === "prod") return "unidesk-frontend"; if (service.id === "frontend" && environment === "dev") return "unidesk-frontend:dev"; @@ -970,6 +1011,44 @@ function artifactConsumerTargetImageHint(service: UniDeskMicroserviceConfig, env return container; } +function deployJsonExecutorMirrors( + service: DeployManifestService, + serviceConfig: UniDeskMicroserviceConfig, + environment: DeployEnvironment, + planKind: ArtifactConsumerPlanKind, + planTarget: Record | null, +): DeployJsonExecutorMirror[] { + const mirrors: DeployJsonExecutorMirror[] = []; + mirrors.push({ + surface: "deploy-executor-plan", + artifact: { + kind: "source-build", + repository: `unidesk/${service.id}`, + tag: "commitId", + }, + consumer: { + kind: planKind === "d601-k3s-managed" ? "d601-k3s-managed" : planKind, + noRuntimeSourceBuild: planKind !== "d601-dev-target-side-build", + target: planTarget === null ? undefined : { + namespace: typeof planTarget.namespace === "string" ? planTarget.namespace : undefined, + deployment: typeof planTarget.deployment === "string" ? planTarget.deployment : undefined, + service: typeof planTarget.service === "string" ? planTarget.service : undefined, + containerName: typeof planTarget.containerName === "string" ? planTarget.containerName : undefined, + stableImage: typeof planTarget.targetImage === "string" ? planTarget.targetImage : undefined, + manifestRepoPath: typeof planTarget.manifest === "string" ? planTarget.manifest : undefined, + }, + }, + runtime: { + containerPort: serviceConfig.backend.nodePort, + servicePort: serviceConfig.backend.nodePort, + healthPath: serviceConfig.backend.healthPath, + }, + }); + const manifestMirror = k3sManifestExecutorMirror(service); + if (manifestMirror !== null) mirrors.push(manifestMirror); + return mirrors; +} + function artifactConsumerPlanValidation(service: UniDeskMicroserviceConfig, environment: DeployEnvironment): string[] { const kind = artifactConsumerPlanKind(service, environment); if (kind === "d601-dev-target-side-build") { @@ -2969,9 +3048,24 @@ function environmentDryRunPlan( const planTarget = serviceConfig === null ? null : artifactConsumerPlanTarget(serviceConfig, environment); const runtimeSecrets = serviceConfig === null ? undefined : redactedSecretContractForService(config, serviceConfig, environment); const unsupportedReason = unsupported ? unsupportedEnvironmentPlanReason(service.id, environment) : null; + const deployJsonContract = hasDeployJsonExecutorContract(service); + const effectivePlanKind = deployJsonContract + ? artifactConsumerPlanKindFromDeployJson(service, planKind) + : planKind; + const deployJsonPlanTarget = serviceConfig === null + ? null + : artifactConsumerPlanTargetFromDeployJson(service, serviceConfig, environment, planTarget); + const drifts = deployJsonContract && serviceConfig !== null && !unsupported + ? compareDeployJsonExecutorMirrors(service, environment, deployJsonExecutorMirrors(service, serviceConfig, environment, effectivePlanKind, deployJsonPlanTarget)) + : []; + if (drifts.length > 0) return deployJsonDriftResult(service, environment, drifts); const effectiveTarget = unsupported ? unsupportedPlanTarget(service.id, environment, unsupportedReason ?? "unsupported") - : planTarget; + : deployJsonContract + ? deployJsonPlanTarget + : planTarget; + const registryRepository = service.artifact?.repository ?? `unidesk/${service.id}`; + const stableImage = service.consumer?.target.stableImage; return { id: service.id, repo: service.repo, @@ -2989,13 +3083,13 @@ function environmentDryRunPlan( dryRunOnly: true, blockedReason: "service is missing from config.json and no synthetic deploy service is available for this environment", } : { - consumerKind: unsupported ? "unsupported" : planKind, - registryImage: unsupported ? null : `127.0.0.1:5000/unidesk/${service.id}:${service.commitId}`, + consumerKind: unsupported ? "unsupported" : effectivePlanKind, + registryImage: unsupported ? null : `127.0.0.1:5000/${registryRepository}:${service.commitId}`, registry: unsupported ? null : { endpoint: "http://127.0.0.1:5000", - repository: `unidesk/${service.id}`, + repository: registryRepository, tag: service.commitId, - imageRef: `127.0.0.1:5000/unidesk/${service.id}:${service.commitId}`, + imageRef: `127.0.0.1:5000/${registryRepository}:${service.commitId}`, digest: null, digestHeader: "Docker-Content-Digest", digestSource: "planned registry manifest HEAD; live apply records Docker-Content-Digest before mutation", @@ -3006,9 +3100,9 @@ function environmentDryRunPlan( deployRef: `${source.ref}:deploy.json#environments.${environment}.services.${service.id}`, }, build: unsupported ? null : { - willCompile: planKind === "d601-dev-target-side-build", + willCompile: effectivePlanKind === "d601-dev-target-side-build", willRunCargoBuild: false, - willRunDockerBuild: planKind === "d601-dev-target-side-build", + willRunDockerBuild: effectivePlanKind === "d601-dev-target-side-build", willRunDockerComposeBuild: false, producerBoundary: service.id === "backend-core" ? "ci publish-backend-core" : "ci publish-user-service", }, @@ -3018,12 +3112,25 @@ function environmentDryRunPlan( "unidesk.ai/source-commit": service.commitId, "unidesk.ai/dockerfile": serviceConfig.repository.dockerfile, }, - noRuntimeSourceBuild: unsupported || planKind !== "d601-dev-target-side-build", + noRuntimeSourceBuild: unsupported || (service.consumer?.noRuntimeSourceBuild ?? effectivePlanKind !== "d601-dev-target-side-build"), dryRunOnly: unsupported || (environment === "prod" && prodArtifactLiveApplyBlockedServiceIds.has(service.id)) || dryRunBlockedReason !== null, blockedReason: unsupportedReason ?? dryRunBlockedReason ?? (environment === "prod" ? prodArtifactLiveApplyBlockedServiceIds.get(service.id) ?? null : null), runtimeSecrets, + sourceOfTruth: deployJsonContract ? deployJsonSourceOfTruth(service, environment) : undefined, + driftCheck: deployJsonContract ? { + ok: true, + mirrors: ["deploy-executor-plan", ...(service.consumer?.kind === "d601-k3s-managed" ? ["k8s-manifest"] : [])], + } : undefined, }, target: effectiveTarget, + runtime: service.runtime === undefined ? undefined : { + sourceOfTruth: "deploy.json", + containerPort: service.runtime.containerPort, + healthPath: service.runtime.healthPath, + memory: service.runtime.memory, + health: service.runtime.health, + }, + runtimeImage: stableImage === undefined ? undefined : deployJsonCommitImage(stableImage, service.commitId), validation: unsupported ? ["unsupported service remains blocked before source materialization, registry pull, manifest update, rollout, or runtime mutation"] : serviceConfig === null @@ -3197,6 +3304,7 @@ async function runArtifactConsumerApplyNow( "--commit", commit, "--source-repo", service.repo, "--deploy-ref", `${deployEnvironmentTargets[environment].gitRef}:deploy.json#environments.${environment}.services.${service.id}`, + ...(options.dryRun && hasDeployJsonExecutorContract(service) ? ["--deploy-json-service", encodeDeployJsonServiceContract(service)] : []), "--env", environment, "--timeout-ms", String(options.timeoutMs), "--run-now",