test: add deploy json executor drift preflight
This commit is contained in:
+37
-1
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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`);
|
||||
@@ -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<string, unknown> {
|
||||
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",
|
||||
|
||||
@@ -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<DeployJsonConsumerTargetContract>;
|
||||
};
|
||||
runtime?: {
|
||||
containerPort?: number;
|
||||
servicePort?: number;
|
||||
healthPath?: string;
|
||||
memory?: {
|
||||
request?: string;
|
||||
limit?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown, path: string): Record<string, unknown> | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return asRecord(value, path);
|
||||
}
|
||||
|
||||
function stringField(record: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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}]`);
|
||||
}
|
||||
+127
-19
@@ -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<string>, 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<string, unknown> | null,
|
||||
): Record<string, unknown> | 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<string, unknown> | 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",
|
||||
|
||||
Reference in New Issue
Block a user