test: add todo-note runtime artifact proof
This commit is contained in:
@@ -166,10 +166,17 @@ services:
|
|||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
todo-note:
|
todo-note:
|
||||||
|
image: todo-note
|
||||||
build:
|
build:
|
||||||
context: /root/todo_note
|
context: /root/todo_note
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: todo-note-backend
|
container_name: todo-note-backend
|
||||||
|
labels:
|
||||||
|
unidesk.ai/deploy-service-id: "${UNIDESK_TODO_NOTE_DEPLOY_SERVICE_ID:-todo-note}"
|
||||||
|
unidesk.ai/deploy-ref: "${UNIDESK_TODO_NOTE_DEPLOY_REF:-deploy.json#environments.prod.services.todo-note}"
|
||||||
|
unidesk.ai/deploy-repo: "${UNIDESK_TODO_NOTE_DEPLOY_REPO:-}"
|
||||||
|
unidesk.ai/deploy-commit: "${UNIDESK_TODO_NOTE_DEPLOY_COMMIT:-}"
|
||||||
|
unidesk.ai/deploy-requested-commit: "${UNIDESK_TODO_NOTE_DEPLOY_REQUESTED_COMMIT:-}"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- database
|
- database
|
||||||
|
|||||||
@@ -167,8 +167,18 @@ for (const item of serviceCases) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertCondition(validation.some((line) => line.includes("registry /v2 manifest")), `${item.serviceId} must plan registry manifest validation`, validation);
|
assertCondition(validation.some((line) => line.includes("registry /v2 manifest")), `${item.serviceId} must plan registry manifest validation`, validation);
|
||||||
assertCondition(validation.some((line) => line.includes("image labels match service id, source commit, and Dockerfile")), `${item.serviceId} must plan image label validation`, validation);
|
assertCondition(validation.some((line) => line.includes("image labels match service id, source repo, source commit, and Dockerfile")), `${item.serviceId} must plan image label validation`, validation);
|
||||||
|
if (item.serviceId === "todo-note") {
|
||||||
|
const runtimeProof = asRecord(plan.runtimeProof, "todo-note runtimeProof");
|
||||||
|
const requiredEnvKeys = asArray(runtimeProof.requiredEnvKeys, "todo-note runtime proof env keys").map(String);
|
||||||
|
assertCondition(runtimeProof.kind === "compose-container-runtime-metadata", "todo-note runtime proof kind mismatch", runtimeProof);
|
||||||
|
assertCondition(runtimeProof.sourceDirectoryUsed === false, "todo-note runtime proof must not use source directory guesses", runtimeProof);
|
||||||
|
assertCondition(requiredEnvKeys.includes("UNIDESK_DEPLOY_COMMIT"), "todo-note runtime proof should use generic deploy commit env", runtimeProof);
|
||||||
|
assertCondition(requiredEnvKeys.includes("UNIDESK_TODO_NOTE_DEPLOY_COMMIT"), "todo-note runtime proof should use service deploy commit env", runtimeProof);
|
||||||
|
assertCondition(validation.some((line) => line.includes("Compose container runtime metadata") && line.includes("not source directory guesses")), "todo-note must plan synthetic runtime proof", validation);
|
||||||
|
} else {
|
||||||
assertCondition(validation.some((line) => line.includes("health probe succeeds") && line.includes("deploy.commit/deploy.requestedCommit")), `${item.serviceId} must plan health commit validation`, validation);
|
assertCondition(validation.some((line) => line.includes("health probe succeeds") && line.includes("deploy.commit/deploy.requestedCommit")), `${item.serviceId} must plan health commit validation`, validation);
|
||||||
|
}
|
||||||
assertCondition(!JSON.stringify(plan).includes("docker compose build"), `${item.serviceId} dry-run must not include compose build`, plan);
|
assertCondition(!JSON.stringify(plan).includes("docker compose build"), `${item.serviceId} dry-run must not include compose build`, plan);
|
||||||
assertCondition(!JSON.stringify(plan).includes("server rebuild"), `${item.serviceId} dry-run must not include server rebuild`, plan);
|
assertCondition(!JSON.stringify(plan).includes("server rebuild"), `${item.serviceId} dry-run must not include server rebuild`, plan);
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,9 @@ function assertCommonDryRun(plan: JsonRecord, contract: ServiceContract, environ
|
|||||||
assertCondition(plan.providerId === "D601", `${contract.serviceId} ${environment} provider mismatch`, plan);
|
assertCondition(plan.providerId === "D601", `${contract.serviceId} ${environment} provider mismatch`, plan);
|
||||||
assertCondition(plan.serviceId === contract.serviceId, `${contract.serviceId} ${environment} service id mismatch`, plan);
|
assertCondition(plan.serviceId === contract.serviceId, `${contract.serviceId} ${environment} service id mismatch`, plan);
|
||||||
assertCondition(plan.commit === contract.desiredCommit, `${contract.serviceId} ${environment} commit mismatch`, plan);
|
assertCondition(plan.commit === contract.desiredCommit, `${contract.serviceId} ${environment} commit mismatch`, plan);
|
||||||
assertCondition(plan.deployRef === `deploy.json#environments.${environment}.services.${contract.serviceId}`, `${contract.serviceId} ${environment} deployRef mismatch`, plan);
|
const deployRef = `deploy.json#environments.${environment}.services.${contract.serviceId}`;
|
||||||
|
const allowedDeployRefs = [deployRef, `origin/master:${deployRef}`];
|
||||||
|
assertCondition(allowedDeployRefs.includes(String(plan.deployRef)), `${contract.serviceId} ${environment} deployRef mismatch`, plan);
|
||||||
assertCondition(plan.sourceImage === `127.0.0.1:5000/${contract.registryRepository}:${contract.desiredCommit}`, `${contract.serviceId} ${environment} source image mismatch`, plan);
|
assertCondition(plan.sourceImage === `127.0.0.1:5000/${contract.registryRepository}:${contract.desiredCommit}`, `${contract.serviceId} ${environment} source image mismatch`, plan);
|
||||||
assertCondition(source.repo === contract.sourceRepo, `${contract.serviceId} ${environment} source repo mismatch`, source);
|
assertCondition(source.repo === contract.sourceRepo, `${contract.serviceId} ${environment} source repo mismatch`, source);
|
||||||
assertCondition(source.commit === contract.desiredCommit, `${contract.serviceId} ${environment} source commit mismatch`, source);
|
assertCondition(source.commit === contract.desiredCommit, `${contract.serviceId} ${environment} source commit mismatch`, source);
|
||||||
@@ -167,6 +169,7 @@ function assertCommonDryRun(plan: JsonRecord, contract: ServiceContract, environ
|
|||||||
assertCondition(String(registry.digestSource ?? "").includes("live apply must read this digest"), `${contract.serviceId} ${environment} digest source mismatch`, registry);
|
assertCondition(String(registry.digestSource ?? "").includes("live apply must read this digest"), `${contract.serviceId} ${environment} digest source mismatch`, registry);
|
||||||
assertCondition(registryProbe.method === "HEAD", `${contract.serviceId} ${environment} registry probe must be HEAD`, registryProbe);
|
assertCondition(registryProbe.method === "HEAD", `${contract.serviceId} ${environment} registry probe must be HEAD`, registryProbe);
|
||||||
assertCondition(labels["unidesk.ai/service-id"] === contract.serviceId, `${contract.serviceId} ${environment} service label mismatch`, labels);
|
assertCondition(labels["unidesk.ai/service-id"] === contract.serviceId, `${contract.serviceId} ${environment} service label mismatch`, labels);
|
||||||
|
assertCondition(labels["unidesk.ai/source-repo"] === contract.sourceRepo, `${contract.serviceId} ${environment} source repo label mismatch`, labels);
|
||||||
assertCondition(labels["unidesk.ai/source-commit"] === contract.desiredCommit, `${contract.serviceId} ${environment} commit label mismatch`, labels);
|
assertCondition(labels["unidesk.ai/source-commit"] === contract.desiredCommit, `${contract.serviceId} ${environment} commit label mismatch`, labels);
|
||||||
assertCondition(labels["unidesk.ai/dockerfile"] === contract.dockerfile, `${contract.serviceId} ${environment} Dockerfile label mismatch`, labels);
|
assertCondition(labels["unidesk.ai/dockerfile"] === contract.dockerfile, `${contract.serviceId} ${environment} Dockerfile label mismatch`, labels);
|
||||||
assertCondition(build.willCompile === false, `${contract.serviceId} ${environment} CD must not compile`, build);
|
assertCondition(build.willCompile === false, `${contract.serviceId} ${environment} CD must not compile`, build);
|
||||||
@@ -198,7 +201,13 @@ function assertComposeDryRun(plan: JsonRecord, contract: ServiceContract, enviro
|
|||||||
assertCondition(target.composeService === "todo-note", `${contract.serviceId} ${environment} compose service mismatch`, target);
|
assertCondition(target.composeService === "todo-note", `${contract.serviceId} ${environment} compose service mismatch`, target);
|
||||||
assertCondition(target.containerName === "todo-note-backend", `${contract.serviceId} ${environment} container mismatch`, target);
|
assertCondition(target.containerName === "todo-note-backend", `${contract.serviceId} ${environment} container mismatch`, target);
|
||||||
assertCondition(target.deployCommandShape === "docker compose up -d --no-build --no-deps --force-recreate todo-note", `${contract.serviceId} ${environment} command shape mismatch`, target);
|
assertCondition(target.deployCommandShape === "docker compose up -d --no-build --no-deps --force-recreate todo-note", `${contract.serviceId} ${environment} command shape mismatch`, target);
|
||||||
assertCondition(validation.some((line) => line.includes("deploy.commit/deploy.requestedCommit")), `${contract.serviceId} ${environment} validation must require health deploy metadata`, validation);
|
const runtimeProof = asRecord(plan.runtimeProof, `${contract.serviceId} ${environment} runtimeProof`);
|
||||||
|
const requiredEnvKeys = asArray(runtimeProof.requiredEnvKeys, `${contract.serviceId} ${environment} runtime proof env keys`).map(String);
|
||||||
|
assertCondition(runtimeProof.kind === "compose-container-runtime-metadata", `${contract.serviceId} ${environment} runtime proof kind mismatch`, runtimeProof);
|
||||||
|
assertCondition(runtimeProof.sourceDirectoryUsed === false, `${contract.serviceId} ${environment} runtime proof must not use source directory guesses`, runtimeProof);
|
||||||
|
assertCondition(requiredEnvKeys.includes("UNIDESK_DEPLOY_COMMIT"), `${contract.serviceId} ${environment} runtime proof must use generic commit env`, runtimeProof);
|
||||||
|
assertCondition(requiredEnvKeys.includes("UNIDESK_TODO_NOTE_DEPLOY_COMMIT"), `${contract.serviceId} ${environment} runtime proof must use service commit env`, runtimeProof);
|
||||||
|
assertCondition(validation.some((line) => line.includes("deploy.commit/deploy.requestedCommit") && line.includes("Compose container runtime metadata")), `${contract.serviceId} ${environment} validation must require synthetic health deploy metadata`, validation);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertDryRuns(): Promise<void> {
|
async function assertDryRuns(): Promise<void> {
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ function assertCommonContract(result: JsonRecord, item: ServiceCase): void {
|
|||||||
assertCondition(build.willRunDockerComposeBuild === false, `${item.serviceId} dry-run must not run docker compose build`, build);
|
assertCondition(build.willRunDockerComposeBuild === false, `${item.serviceId} dry-run must not run docker compose build`, build);
|
||||||
assertCondition(build.producerBoundary === "ci publish-user-service", `${item.serviceId} producer boundary mismatch`, build);
|
assertCondition(build.producerBoundary === "ci publish-user-service", `${item.serviceId} producer boundary mismatch`, build);
|
||||||
assertCondition(labels["unidesk.ai/service-id"] === item.serviceId, `${item.serviceId} service label mismatch`, labels);
|
assertCondition(labels["unidesk.ai/service-id"] === item.serviceId, `${item.serviceId} service label mismatch`, labels);
|
||||||
|
assertCondition(labels["unidesk.ai/source-repo"] === item.sourceRepo, `${item.serviceId} source repo label mismatch`, labels);
|
||||||
assertCondition(labels["unidesk.ai/source-commit"] === item.commit, `${item.serviceId} commit label mismatch`, labels);
|
assertCondition(labels["unidesk.ai/source-commit"] === item.commit, `${item.serviceId} commit label mismatch`, labels);
|
||||||
assertCondition(labels["unidesk.ai/dockerfile"] === item.dockerfile, `${item.serviceId} dockerfile label mismatch`, labels);
|
assertCondition(labels["unidesk.ai/dockerfile"] === item.dockerfile, `${item.serviceId} dockerfile label mismatch`, labels);
|
||||||
assertCondition(registryProbe.method === "HEAD", `${item.serviceId} registry probe must be HEAD`, registryProbe);
|
assertCondition(registryProbe.method === "HEAD", `${item.serviceId} registry probe must be HEAD`, registryProbe);
|
||||||
@@ -160,6 +161,14 @@ function assertCommonContract(result: JsonRecord, item: ServiceCase): void {
|
|||||||
for (const snippet of item.expectedValidationSnippets) {
|
for (const snippet of item.expectedValidationSnippets) {
|
||||||
assertCondition(validation.some((line) => line.includes(snippet)), `${item.serviceId} validation should include ${snippet}`, validation);
|
assertCondition(validation.some((line) => line.includes(snippet)), `${item.serviceId} validation should include ${snippet}`, validation);
|
||||||
}
|
}
|
||||||
|
if (item.serviceId === "todo-note") {
|
||||||
|
const runtimeProof = asRecord(result.runtimeProof, "todo-note runtimeProof");
|
||||||
|
const requiredEnvKeys = asArray(runtimeProof.requiredEnvKeys, "todo-note runtime proof env keys").map(String);
|
||||||
|
assertCondition(runtimeProof.kind === "compose-container-runtime-metadata", "todo-note runtime proof kind mismatch", runtimeProof);
|
||||||
|
assertCondition(runtimeProof.sourceDirectoryUsed === false, "todo-note runtime proof must not use source directory guesses", runtimeProof);
|
||||||
|
assertCondition(requiredEnvKeys.includes("UNIDESK_DEPLOY_REQUESTED_COMMIT"), "todo-note proof should use generic requested commit env", runtimeProof);
|
||||||
|
assertCondition(requiredEnvKeys.includes("UNIDESK_TODO_NOTE_DEPLOY_REQUESTED_COMMIT"), "todo-note proof should use service requested commit env", runtimeProof);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cases: ServiceCase[] = [
|
const cases: ServiceCase[] = [
|
||||||
@@ -214,7 +223,8 @@ const cases: ServiceCase[] = [
|
|||||||
expectedValidationSnippets: [
|
expectedValidationSnippets: [
|
||||||
"D601 registry /v2 manifest exists for the commit tag",
|
"D601 registry /v2 manifest exists for the commit tag",
|
||||||
"running Compose container image label matches the requested commit",
|
"running Compose container image label matches the requested commit",
|
||||||
"todo-note runtime health probe succeeds and reports deploy.commit/deploy.requestedCommit matching the artifact commit",
|
"todo-note runtime health proof synthesizes deploy.commit/deploy.requestedCommit from Compose container runtime metadata",
|
||||||
|
"not source directory guesses",
|
||||||
],
|
],
|
||||||
rollbackType: "compose-retag-recreate",
|
rollbackType: "compose-retag-recreate",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ interface ArtifactConsumerTarget {
|
|||||||
projectHint?: string;
|
projectHint?: string;
|
||||||
requiredRuntimeSecrets?: RuntimeSecretRequirement[];
|
requiredRuntimeSecrets?: RuntimeSecretRequirement[];
|
||||||
authHealthGate?: AuthHealthGate;
|
authHealthGate?: AuthHealthGate;
|
||||||
|
syntheticHealthDeployProof?: SyntheticHealthDeployProof;
|
||||||
};
|
};
|
||||||
k3s?: {
|
k3s?: {
|
||||||
namespace: string;
|
namespace: string;
|
||||||
@@ -184,6 +185,11 @@ interface AuthHealthGate {
|
|||||||
recoveryHint: string;
|
recoveryHint: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SyntheticHealthDeployProof {
|
||||||
|
kind: "compose-container-runtime-metadata";
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ComposeArtifactRuntime {
|
interface ComposeArtifactRuntime {
|
||||||
workDir: string;
|
workDir: string;
|
||||||
composeFile: string;
|
composeFile: string;
|
||||||
@@ -230,8 +236,13 @@ export interface RuntimeSecretContract {
|
|||||||
dryRunDisposition: "not-required" | "ready-for-live-apply" | "secret-source-blocked";
|
dryRunDisposition: "not-required" | "ready-for-live-apply" | "secret-source-blocked";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const todoNoteSyntheticHealthDeployProof: SyntheticHealthDeployProof = {
|
||||||
|
kind: "compose-container-runtime-metadata",
|
||||||
|
description: "todo-note /api/health does not natively expose deploy metadata, so the artifact consumer synthesizes health.deploy from the recreated container's UNIDESK_DEPLOY_* env and verifies it against image/container labels.",
|
||||||
|
};
|
||||||
|
|
||||||
function todoNoteHealthProbeCommand(): string {
|
function todoNoteHealthProbeCommand(): string {
|
||||||
return "bun -e \"fetch('http://127.0.0.1:4211/api/health').then(async r=>{const text=await r.text(); let body; try{body=JSON.parse(text)}catch{body={ok:r.ok,raw:text}}; const deploy=body.deploy&&typeof body.deploy==='object'&&!Array.isArray(body.deploy)?body.deploy:{}; body.deploy={...deploy,serviceId:deploy.serviceId||process.env.UNIDESK_DEPLOY_SERVICE_ID||'todo-note',ref:deploy.ref||process.env.UNIDESK_DEPLOY_REF||'',repo:deploy.repo||process.env.UNIDESK_DEPLOY_REPO||'',commit:deploy.commit||process.env.UNIDESK_DEPLOY_COMMIT||'',requestedCommit:deploy.requestedCommit||process.env.UNIDESK_DEPLOY_REQUESTED_COMMIT||''}; console.log(JSON.stringify(body)); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"";
|
return "bun -e \"fetch('http://127.0.0.1:4211/api/health').then(async r=>{const text=await r.text(); let body; try{body=JSON.parse(text)}catch{body={ok:r.ok,raw:text}}; console.log(JSON.stringify(body)); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const baiduNetdiskRuntimeSecretRequirements: RuntimeSecretRequirement[] = [
|
export const baiduNetdiskRuntimeSecretRequirements: RuntimeSecretRequirement[] = [
|
||||||
@@ -777,6 +788,7 @@ const artifactConsumerSpecs: Record<string, ArtifactConsumerSpec> = {
|
|||||||
deployEnvPrefix: "UNIDESK_TODO_NOTE_DEPLOY",
|
deployEnvPrefix: "UNIDESK_TODO_NOTE_DEPLOY",
|
||||||
healthProbeCommand: todoNoteHealthProbeCommand(),
|
healthProbeCommand: todoNoteHealthProbeCommand(),
|
||||||
requireHealthCommit: true,
|
requireHealthCommit: true,
|
||||||
|
syntheticHealthDeployProof: todoNoteSyntheticHealthDeployProof,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
prod: {
|
prod: {
|
||||||
@@ -789,6 +801,7 @@ const artifactConsumerSpecs: Record<string, ArtifactConsumerSpec> = {
|
|||||||
deployEnvPrefix: "UNIDESK_TODO_NOTE_DEPLOY",
|
deployEnvPrefix: "UNIDESK_TODO_NOTE_DEPLOY",
|
||||||
healthProbeCommand: todoNoteHealthProbeCommand(),
|
healthProbeCommand: todoNoteHealthProbeCommand(),
|
||||||
requireHealthCommit: true,
|
requireHealthCommit: true,
|
||||||
|
syntheticHealthDeployProof: todoNoteSyntheticHealthDeployProof,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2166,6 +2179,70 @@ function composeArtifactEnvValues(spec: ArtifactConsumerSpec, target: ArtifactCo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syntheticComposeHealthDeployScript(compose: NonNullable<ArtifactConsumerTarget["compose"]>, commit: string, sourceRepo: string): string[] {
|
||||||
|
if (compose.syntheticHealthDeployProof === undefined) return [];
|
||||||
|
const prefix = compose.deployEnvPrefix;
|
||||||
|
const envKeys = [
|
||||||
|
"UNIDESK_DEPLOY_SERVICE_ID",
|
||||||
|
"UNIDESK_DEPLOY_REF",
|
||||||
|
"UNIDESK_DEPLOY_REPO",
|
||||||
|
"UNIDESK_DEPLOY_COMMIT",
|
||||||
|
"UNIDESK_DEPLOY_REQUESTED_COMMIT",
|
||||||
|
`${prefix}_SERVICE_ID`,
|
||||||
|
`${prefix}_REF`,
|
||||||
|
`${prefix}_REPO`,
|
||||||
|
`${prefix}_COMMIT`,
|
||||||
|
`${prefix}_REQUESTED_COMMIT`,
|
||||||
|
];
|
||||||
|
const envKeysJson = JSON.stringify(envKeys);
|
||||||
|
return [
|
||||||
|
"runtime_metadata_json=$(docker inspect \"$cid\" --format '{{json .Config.Env}}')",
|
||||||
|
"container_label_commit=$(docker inspect -f '{{ index .Config.Labels \"unidesk.ai/deploy-commit\" }}' \"$cid\")",
|
||||||
|
"container_label_requested_commit=$(docker inspect -f '{{ index .Config.Labels \"unidesk.ai/deploy-requested-commit\" }}' \"$cid\")",
|
||||||
|
"container_label_repo=$(docker inspect -f '{{ index .Config.Labels \"unidesk.ai/deploy-repo\" }}' \"$cid\")",
|
||||||
|
"container_label_ref=$(docker inspect -f '{{ index .Config.Labels \"unidesk.ai/deploy-ref\" }}' \"$cid\")",
|
||||||
|
`python3 - "$health_json" "$runtime_metadata_json" ${shellQuote(envKeysJson)} ${shellQuote(prefix)} ${shellQuote(commit)} ${shellQuote(sourceRepo)} "$actual_commit" "$actual_service" "$actual_repo" "$container_label_commit" "$container_label_requested_commit" "$container_label_repo" "$container_label_ref" <<'PY'`,
|
||||||
|
"import json, sys",
|
||||||
|
"path, env_json, keys_json, prefix, expected_commit, expected_repo, image_commit, image_service, image_repo, label_commit, label_requested_commit, label_repo, label_ref = sys.argv[1:]",
|
||||||
|
"body = json.load(open(path, encoding='utf-8'))",
|
||||||
|
"env_items = json.loads(env_json)",
|
||||||
|
"allow = set(json.loads(keys_json))",
|
||||||
|
"env = {}",
|
||||||
|
"for item in env_items:",
|
||||||
|
" if not isinstance(item, str) or '=' not in item:",
|
||||||
|
" continue",
|
||||||
|
" key, value = item.split('=', 1)",
|
||||||
|
" if key in allow:",
|
||||||
|
" env[key] = value",
|
||||||
|
"deploy = body.get('deploy') if isinstance(body, dict) and isinstance(body.get('deploy'), dict) else {}",
|
||||||
|
"synthetic = {",
|
||||||
|
" 'serviceId': deploy.get('serviceId') or env.get('UNIDESK_DEPLOY_SERVICE_ID') or env.get(prefix + '_SERVICE_ID') or image_service,",
|
||||||
|
" 'ref': deploy.get('ref') or env.get('UNIDESK_DEPLOY_REF') or env.get(prefix + '_REF') or label_ref,",
|
||||||
|
" 'repo': deploy.get('repo') or env.get('UNIDESK_DEPLOY_REPO') or env.get(prefix + '_REPO') or label_repo or image_repo,",
|
||||||
|
" 'commit': deploy.get('commit') or env.get('UNIDESK_DEPLOY_COMMIT') or env.get(prefix + '_COMMIT') or label_commit or image_commit,",
|
||||||
|
" 'requestedCommit': deploy.get('requestedCommit') or env.get('UNIDESK_DEPLOY_REQUESTED_COMMIT') or env.get(prefix + '_REQUESTED_COMMIT') or label_requested_commit,",
|
||||||
|
"}",
|
||||||
|
"body['deploy'] = synthetic",
|
||||||
|
"body['deployProof'] = {",
|
||||||
|
" 'kind': 'compose-container-runtime-metadata',",
|
||||||
|
" 'sources': ['service-health', 'container-env', 'container-labels', 'image-labels'],",
|
||||||
|
" 'sourceDirectoryUsed': False,",
|
||||||
|
" 'envKeysUsed': sorted([key for key in env if key.startswith('UNIDESK_DEPLOY_') or key.startswith(prefix + '_')]),",
|
||||||
|
"}",
|
||||||
|
"if synthetic.get('commit') != expected_commit or synthetic.get('requestedCommit') != expected_commit:",
|
||||||
|
" raise SystemExit('synthetic deploy commit mismatch: ' + json.dumps(synthetic, sort_keys=True))",
|
||||||
|
"if synthetic.get('repo') != expected_repo:",
|
||||||
|
" raise SystemExit('synthetic deploy repo mismatch: ' + json.dumps(synthetic, sort_keys=True))",
|
||||||
|
"if synthetic.get('serviceId') != image_service:",
|
||||||
|
" raise SystemExit('synthetic deploy service mismatch: ' + json.dumps(synthetic, sort_keys=True))",
|
||||||
|
"with open(path, 'w', encoding='utf-8') as handle:",
|
||||||
|
" json.dump(body, handle, separators=(',', ':'))",
|
||||||
|
" handle.write('\\n')",
|
||||||
|
"print('synthetic_health_deploy_proof=compose-container-runtime-metadata sourceDirectoryUsed=false')",
|
||||||
|
"PY",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget): Promise<Record<string, unknown>> {
|
async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget): Promise<Record<string, unknown>> {
|
||||||
const commit = options.commit;
|
const commit = options.commit;
|
||||||
if (commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
|
if (commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
|
||||||
@@ -2202,6 +2279,7 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec:
|
|||||||
return { ok: false, serviceId: spec.serviceId, error: "D601 artifact registry is not healthy", health };
|
return { ok: false, serviceId: spec.serviceId, error: "D601 artifact registry is not healthy", health };
|
||||||
}
|
}
|
||||||
const sourceImage = artifactImageRef(options, spec, commit);
|
const sourceImage = artifactImageRef(options, spec, commit);
|
||||||
|
const sourceRepo = sourceRepoFor(options, spec);
|
||||||
const composeImage = options.targetImage ?? target.targetImage;
|
const composeImage = options.targetImage ?? target.targetImage;
|
||||||
const commitImage = `${composeImage}:${commit}`;
|
const commitImage = `${composeImage}:${commit}`;
|
||||||
const registryProbe = runRemoteScript(options, registryArtifactProbeScript(options, spec, commit), Math.max(options.timeoutMs, 120_000));
|
const registryProbe = runRemoteScript(options, registryArtifactProbeScript(options, spec, commit), Math.max(options.timeoutMs, 120_000));
|
||||||
@@ -2227,7 +2305,7 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const localLoadedImage = sourceImage;
|
const localLoadedImage = sourceImage;
|
||||||
const labelFailure = verifyLocalArtifactLabels(localLoadedImage, spec, commit, sourceRepoFor(options, spec));
|
const labelFailure = verifyLocalArtifactLabels(localLoadedImage, spec, commit, sourceRepo);
|
||||||
if (labelFailure !== null) return { ...labelFailure, registryProbe: commandTail(registryProbe) };
|
if (labelFailure !== null) return { ...labelFailure, registryProbe: commandTail(registryProbe) };
|
||||||
const tag = runCommand(["docker", "tag", localLoadedImage, composeImage], repoRoot);
|
const tag = runCommand(["docker", "tag", localLoadedImage, composeImage], repoRoot);
|
||||||
if (tag.exitCode !== 0 || tag.timedOut) {
|
if (tag.exitCode !== 0 || tag.timedOut) {
|
||||||
@@ -2265,9 +2343,14 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec:
|
|||||||
`cid=$(docker ps -q --filter label=com.docker.compose.project=${shellQuote(composeProject)} --filter label=com.docker.compose.service=${shellQuote(serviceName)} --filter label=com.docker.compose.oneoff=False | head -1)`,
|
`cid=$(docker ps -q --filter label=com.docker.compose.project=${shellQuote(composeProject)} --filter label=com.docker.compose.service=${shellQuote(serviceName)} --filter label=com.docker.compose.oneoff=False | head -1)`,
|
||||||
"image_id=$(docker inspect -f '{{.Image}}' \"$cid\")",
|
"image_id=$(docker inspect -f '{{.Image}}' \"$cid\")",
|
||||||
"actual_commit=$(docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}' \"$image_id\")",
|
"actual_commit=$(docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}' \"$image_id\")",
|
||||||
|
"actual_service=$(docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/service-id\" }}' \"$image_id\")",
|
||||||
|
"actual_repo=$(docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/source-repo\" }}' \"$image_id\")",
|
||||||
`test "$actual_commit" = ${shellQuote(commit)}`,
|
`test "$actual_commit" = ${shellQuote(commit)}`,
|
||||||
|
`test "$actual_service" = ${shellQuote(spec.serviceId)}`,
|
||||||
|
`test "$actual_repo" = ${shellQuote(sourceRepo)}`,
|
||||||
`health_json=/tmp/unidesk-${safeName(spec.serviceId)}-health.json`,
|
`health_json=/tmp/unidesk-${safeName(spec.serviceId)}-health.json`,
|
||||||
`docker exec "$cid" sh -lc ${shellQuote(target.compose.healthProbeCommand)} > "$health_json"`,
|
`docker exec "$cid" sh -lc ${shellQuote(target.compose.healthProbeCommand)} > "$health_json"`,
|
||||||
|
...syntheticComposeHealthDeployScript(target.compose, commit, sourceRepo),
|
||||||
"cat \"$health_json\"",
|
"cat \"$health_json\"",
|
||||||
...authHealthGateScript(target.compose.authHealthGate, "\"$health_json\""),
|
...authHealthGateScript(target.compose.authHealthGate, "\"$health_json\""),
|
||||||
...(target.compose.requireHealthCommit ? [
|
...(target.compose.requireHealthCommit ? [
|
||||||
@@ -2312,6 +2395,7 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec:
|
|||||||
imageLabelCommit: commit,
|
imageLabelCommit: commit,
|
||||||
serviceHealthCommit: target.compose.requireHealthCommit ? commit : "not-required",
|
serviceHealthCommit: target.compose.requireHealthCommit ? commit : "not-required",
|
||||||
serviceHealthRequestedCommit: target.compose.requireHealthCommit ? commit : "not-required",
|
serviceHealthRequestedCommit: target.compose.requireHealthCommit ? commit : "not-required",
|
||||||
|
runtimeProof: target.compose.syntheticHealthDeployProof === undefined ? "native-health" : target.compose.syntheticHealthDeployProof,
|
||||||
requiredRuntimeSecrets: secretPreflight.requirements,
|
requiredRuntimeSecrets: secretPreflight.requirements,
|
||||||
runtimeSecrets,
|
runtimeSecrets,
|
||||||
authHealthGate: target.compose.authHealthGate === undefined ? "not-required" : {
|
authHealthGate: target.compose.authHealthGate === undefined ? "not-required" : {
|
||||||
@@ -2652,6 +2736,8 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti
|
|||||||
...(spec.kind === "d601-compose" ? ["running Compose container image label matches the requested commit"] : []),
|
...(spec.kind === "d601-compose" ? ["running Compose container image label matches the requested commit"] : []),
|
||||||
verificationBlocked
|
verificationBlocked
|
||||||
? `blocked: ${spec.runtimeVerificationBlockReason}`
|
? `blocked: ${spec.runtimeVerificationBlockReason}`
|
||||||
|
: target.compose.requireHealthCommit && target.compose.syntheticHealthDeployProof !== undefined
|
||||||
|
? `${spec.serviceId} runtime health proof synthesizes deploy.commit/deploy.requestedCommit from Compose container runtime metadata and verifies it against image/container labels, not source directory guesses`
|
||||||
: target.compose.requireHealthCommit
|
: target.compose.requireHealthCommit
|
||||||
? `${spec.serviceId} runtime health probe succeeds and reports deploy.commit/deploy.requestedCommit matching the artifact commit`
|
? `${spec.serviceId} runtime health probe succeeds and reports deploy.commit/deploy.requestedCommit matching the artifact commit`
|
||||||
: `${spec.serviceId} /health succeeds for the recreated container`,
|
: `${spec.serviceId} /health succeeds for the recreated container`,
|
||||||
@@ -2668,6 +2754,26 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti
|
|||||||
requiredAuthFields: target.compose.authHealthGate.requiredFields,
|
requiredAuthFields: target.compose.authHealthGate.requiredFields,
|
||||||
dryRunDisposition: "pending-live-check",
|
dryRunDisposition: "pending-live-check",
|
||||||
},
|
},
|
||||||
|
runtimeProof: target.compose.syntheticHealthDeployProof === undefined ? {
|
||||||
|
kind: "native-health-deploy-metadata",
|
||||||
|
sourceDirectoryUsed: false,
|
||||||
|
} : {
|
||||||
|
...target.compose.syntheticHealthDeployProof,
|
||||||
|
sourceDirectoryUsed: false,
|
||||||
|
sources: ["service-health", "container-env", "container-labels", "image-labels"],
|
||||||
|
requiredEnvKeys: [
|
||||||
|
"UNIDESK_DEPLOY_SERVICE_ID",
|
||||||
|
"UNIDESK_DEPLOY_REF",
|
||||||
|
"UNIDESK_DEPLOY_REPO",
|
||||||
|
"UNIDESK_DEPLOY_COMMIT",
|
||||||
|
"UNIDESK_DEPLOY_REQUESTED_COMMIT",
|
||||||
|
`${target.compose.deployEnvPrefix}_SERVICE_ID`,
|
||||||
|
`${target.compose.deployEnvPrefix}_REF`,
|
||||||
|
`${target.compose.deployEnvPrefix}_REPO`,
|
||||||
|
`${target.compose.deployEnvPrefix}_COMMIT`,
|
||||||
|
`${target.compose.deployEnvPrefix}_REQUESTED_COMMIT`,
|
||||||
|
],
|
||||||
|
},
|
||||||
rollback: rollbackInfo(spec, target, environment, commit),
|
rollback: rollbackInfo(spec, target, environment, commit),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1076,6 +1076,9 @@ function artifactConsumerPlanValidation(service: UniDeskMicroserviceConfig, envi
|
|||||||
...base,
|
...base,
|
||||||
"recreates only the selected Compose service with --no-build --no-deps --force-recreate",
|
"recreates only the selected Compose service with --no-build --no-deps --force-recreate",
|
||||||
"verifies running image labels and private service health",
|
"verifies running image labels and private service health",
|
||||||
|
...(service.id === "todo-note" ? [
|
||||||
|
"todo-note runtime proof synthesizes health.deploy.commit/requestedCommit from Compose container env, container labels, and image labels; it does not infer commit from /root/todo_note or any source directory",
|
||||||
|
] : []),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return ["unsupported service remains blocked before source materialization or runtime mutation"];
|
return ["unsupported service remains blocked before source materialization or runtime mutation"];
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { rootPath } from "./src/config";
|
||||||
|
import { runArtifactRegistryCommand } from "./src/artifact-registry";
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
const serviceId = "todo-note";
|
||||||
|
const commit = "a14ce0eb855a685fa17b47adacd54623e72cd2ff";
|
||||||
|
const sourceRepo = "https://gitee.com/Lyon1998/todo_note";
|
||||||
|
|
||||||
|
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
|
||||||
|
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown, label: string): JsonRecord {
|
||||||
|
assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value);
|
||||||
|
return value as JsonRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asArray(value: unknown, label: string): unknown[] {
|
||||||
|
assertCondition(Array.isArray(value), `${label} must be an array`, value);
|
||||||
|
return value as unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDeployApplyDryRun(): JsonRecord {
|
||||||
|
const result = spawnSync("bun", ["scripts/cli.ts", "deploy", "apply", "--env", "prod", "--service", serviceId, "--dry-run"], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 8 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
assertCondition(result.status === 0, "deploy apply dry-run should exit 0", {
|
||||||
|
status: result.status,
|
||||||
|
stdout: result.stdout.slice(-2000),
|
||||||
|
stderr: result.stderr.slice(-2000),
|
||||||
|
});
|
||||||
|
const envelope = asRecord(JSON.parse(result.stdout) as unknown, "deploy apply envelope");
|
||||||
|
assertCondition(envelope.ok === true, "deploy apply dry-run envelope should be ok", envelope);
|
||||||
|
const data = asRecord(envelope.data, "deploy apply data");
|
||||||
|
const results = asArray(data.results, "deploy apply results");
|
||||||
|
assertCondition(results.length === 1, "deploy apply dry-run should include one result", data);
|
||||||
|
return asRecord(results[0], "todo-note deploy apply result");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertTodoNoteComposeYaml(): void {
|
||||||
|
const compose = readFileSync(rootPath("docker-compose.yml"), "utf8");
|
||||||
|
const todoNoteSection = compose.slice(compose.indexOf(" todo-note:"), compose.indexOf(" oa-event-flow:"));
|
||||||
|
assertCondition(todoNoteSection.includes("image: todo-note"), "todo-note Compose service must name a stable image", todoNoteSection);
|
||||||
|
for (const expected of [
|
||||||
|
"unidesk.ai/deploy-service-id",
|
||||||
|
"unidesk.ai/deploy-ref",
|
||||||
|
"unidesk.ai/deploy-repo",
|
||||||
|
"unidesk.ai/deploy-commit",
|
||||||
|
"unidesk.ai/deploy-requested-commit",
|
||||||
|
"UNIDESK_DEPLOY_COMMIT",
|
||||||
|
"UNIDESK_DEPLOY_REQUESTED_COMMIT",
|
||||||
|
]) {
|
||||||
|
assertCondition(todoNoteSection.includes(expected), `todo-note Compose section must include ${expected}`, todoNoteSection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPlan(plan: JsonRecord, label: string): void {
|
||||||
|
const target = asRecord(plan.target, `${label} target`);
|
||||||
|
const source = asRecord(plan.source, `${label} source`);
|
||||||
|
const build = asRecord(plan.build, `${label} build`);
|
||||||
|
const labels = asRecord(plan.requiredLabels, `${label} requiredLabels`);
|
||||||
|
const validation = asArray(plan.validation, `${label} validation`).map(String);
|
||||||
|
const runtimeProof = asRecord(plan.runtimeProof, `${label} runtimeProof`);
|
||||||
|
const sources = asArray(runtimeProof.sources, `${label} runtimeProof.sources`).map(String);
|
||||||
|
const requiredEnvKeys = asArray(runtimeProof.requiredEnvKeys, `${label} runtimeProof.requiredEnvKeys`).map(String);
|
||||||
|
|
||||||
|
assertCondition(plan.ok === true && plan.supported === true, `${label} plan must be supported`, plan);
|
||||||
|
assertCondition(plan.dryRun === true && plan.mutation === false, `${label} plan must be non-mutating`, plan);
|
||||||
|
assertCondition(plan.environment === "prod", `${label} environment mismatch`, plan);
|
||||||
|
assertCondition(plan.serviceId === serviceId, `${label} service id mismatch`, plan);
|
||||||
|
assertCondition(plan.commit === commit, `${label} commit mismatch`, plan);
|
||||||
|
assertCondition(plan.sourceRepo === sourceRepo, `${label} source repo mismatch`, plan);
|
||||||
|
assertCondition(source.repo === sourceRepo && source.commit === commit && source.dockerfile === "Dockerfile", `${label} source mismatch`, source);
|
||||||
|
assertCondition(build.willCompile === false, `${label} dry-run must not compile`, build);
|
||||||
|
assertCondition(build.willRunDockerBuild === false, `${label} dry-run must not build Docker images`, build);
|
||||||
|
assertCondition(build.willRunDockerComposeBuild === false, `${label} dry-run must not run docker compose build`, build);
|
||||||
|
assertCondition(target.kind === "compose", `${label} target kind mismatch`, target);
|
||||||
|
assertCondition(target.runtimeHost === "main-server", `${label} runtime host mismatch`, target);
|
||||||
|
assertCondition(target.composeService === serviceId, `${label} compose service mismatch`, target);
|
||||||
|
assertCondition(target.containerName === "todo-note-backend", `${label} container mismatch`, target);
|
||||||
|
assertCondition(target.targetImage === "todo-note", `${label} target image mismatch`, target);
|
||||||
|
assertCondition(target.runtimeImage === `todo-note:${commit}`, `${label} runtime image mismatch`, target);
|
||||||
|
assertCondition(target.deployCommandShape === "docker compose up -d --no-build --no-deps --force-recreate todo-note", `${label} command shape mismatch`, target);
|
||||||
|
assertCondition(labels["unidesk.ai/service-id"] === serviceId, `${label} service label mismatch`, labels);
|
||||||
|
assertCondition(labels["unidesk.ai/source-repo"] === sourceRepo, `${label} source repo label mismatch`, labels);
|
||||||
|
assertCondition(labels["unidesk.ai/source-commit"] === commit, `${label} source commit label mismatch`, labels);
|
||||||
|
assertCondition(labels["unidesk.ai/dockerfile"] === "Dockerfile", `${label} dockerfile label mismatch`, labels);
|
||||||
|
assertCondition(runtimeProof.kind === "compose-container-runtime-metadata", `${label} runtime proof kind mismatch`, runtimeProof);
|
||||||
|
assertCondition(runtimeProof.sourceDirectoryUsed === false, `${label} runtime proof must not use source directory guesses`, runtimeProof);
|
||||||
|
for (const expected of ["service-health", "container-env", "container-labels", "image-labels"]) {
|
||||||
|
assertCondition(sources.includes(expected), `${label} runtime proof sources should include ${expected}`, runtimeProof);
|
||||||
|
}
|
||||||
|
for (const expected of [
|
||||||
|
"UNIDESK_DEPLOY_COMMIT",
|
||||||
|
"UNIDESK_DEPLOY_REQUESTED_COMMIT",
|
||||||
|
"UNIDESK_TODO_NOTE_DEPLOY_COMMIT",
|
||||||
|
"UNIDESK_TODO_NOTE_DEPLOY_REQUESTED_COMMIT",
|
||||||
|
]) {
|
||||||
|
assertCondition(requiredEnvKeys.includes(expected), `${label} runtime proof env keys should include ${expected}`, runtimeProof);
|
||||||
|
}
|
||||||
|
assertCondition(validation.some((line) => line.includes("Compose container runtime metadata") && line.includes("not source directory guesses")), `${label} validation must name runtime metadata proof`, validation);
|
||||||
|
assertCondition(!JSON.stringify(plan).includes("/root/todo_note"), `${label} dry-run must not rely on the todo-note source directory`, plan);
|
||||||
|
assertCondition(!JSON.stringify(plan).includes("docker compose build"), `${label} dry-run must not mention docker compose build`, plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
assertTodoNoteComposeYaml();
|
||||||
|
assertPlan(runDeployApplyDryRun(), "deploy apply");
|
||||||
|
const artifactPlan = asRecord(await runArtifactRegistryCommand([
|
||||||
|
"deploy-service",
|
||||||
|
"--env",
|
||||||
|
"prod",
|
||||||
|
"--service",
|
||||||
|
serviceId,
|
||||||
|
"--commit",
|
||||||
|
commit,
|
||||||
|
"--dry-run",
|
||||||
|
]), "artifact-registry dry-run");
|
||||||
|
assertPlan(artifactPlan, "artifact-registry");
|
||||||
|
|
||||||
|
process.stdout.write(`${JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
checks: [
|
||||||
|
"todo-note Compose service names the stable artifact image and deploy labels",
|
||||||
|
"deploy apply prod dry-run is a no-build/no-deps main-server Compose artifact consumer",
|
||||||
|
"artifact-registry prod dry-run requires source repo/source commit/Dockerfile image labels",
|
||||||
|
"runtime proof uses container env, container labels, image labels and health output",
|
||||||
|
"runtime proof does not infer commit from /root/todo_note or any source directory",
|
||||||
|
],
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
await main();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user