edfddd2445
* docs: specify cicd yaml target governance * fix: resolve cicd targets from yaml --------- Co-authored-by: Codex <codex@noreply.local>
613 lines
34 KiB
TypeScript
613 lines
34 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. compose-deploy module for scripts/src/artifact-registry.ts.
|
|
// SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets.
|
|
|
|
// Moved mechanically from scripts/src/artifact-registry.ts:2579-3153 for #903.
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { runCommand, type CommandResult } from "../command";
|
|
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
|
|
import { startJob } from "../jobs";
|
|
import {
|
|
compareDeployJsonExecutorMirrors,
|
|
deployJsonCommitImage,
|
|
deployJsonDriftResult,
|
|
deployJsonSourceOfTruth,
|
|
hasDeployJsonExecutorContract,
|
|
k3sManifestExecutorMirror,
|
|
parseDeployJsonServiceContractBase64,
|
|
readDeployJsonServiceContractFromFile,
|
|
type DeployJsonExecutorMirror,
|
|
type DeployJsonServiceContract,
|
|
} from "../deploy-json-contract";
|
|
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
|
|
import { composeRuntimeEnvValue } from "../runtime-env";
|
|
|
|
import { artifactRegistryOptionsTargetSummary, type ArtifactConsumerSpec, type ArtifactConsumerTarget, type ArtifactRegistryOptions } from "./types";
|
|
import { composeArtifactEnvValues, registryArtifactMissingMessage, registryArtifactProbeScript, syntheticComposeHealthDeployScript, verifyLocalArtifactLabels } from "./artifact-probe";
|
|
import { base64, safeName, shellQuote } from "./bundle";
|
|
import { artifactConsumerSpecs } from "./catalog";
|
|
import { artifactConsumerLiveBlockReason, artifactConsumerLivePolicy, artifactConsumerSelfBootstrapGuard, artifactConsumerTarget, artifactImageRef, artifactRegistryDeployJsonMirrors, codeQueueAffectedRuntime, codeQueueExcludedTargets, commandTail, deployJsonServiceForOptions, deployRefFor, registryRepositoryFor, sourceRepoFor, targetCommitImageFor, targetImageFor, unsupportedEnvironment } from "./consumer";
|
|
import { composeLockScript } from "./install";
|
|
import { rollbackInfo } from "./k3s-deploy";
|
|
import { runReadonlyStatus } from "./readonly";
|
|
import { runRemoteScript } from "./remote";
|
|
import { authHealthGateScript, composeArtifactRuntime, composeArtifactSecretPreflight, pullArtifactFromD601, runtimeSecretContractForComposeTarget, runtimeSecretRecommendedActionForService, upsertEnvFileValues } from "./runtime-secrets";
|
|
|
|
export async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget): Promise<Record<string, unknown>> {
|
|
const commit = options.commit;
|
|
if (commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
|
|
if (target.compose === undefined) throw new Error(`${spec.serviceId} missing compose artifact consumer config`);
|
|
const config = readConfig();
|
|
const runtime = composeArtifactRuntime(config, target);
|
|
const secretPreflight = composeArtifactSecretPreflight(runtime.envFile, target.compose.requiredRuntimeSecrets);
|
|
const runtimeSecretsRaw = runtimeSecretContractForComposeTarget(config, target, target.compose.requiredRuntimeSecrets);
|
|
const runtimeSecrets = runtimeSecretsRaw === undefined
|
|
? undefined
|
|
: runtimeSecretRecommendedActionForService(runtimeSecretsRaw, options.environment ?? "prod", spec.serviceId);
|
|
if (!secretPreflight.ok) {
|
|
return {
|
|
ok: false,
|
|
supported: true,
|
|
serviceId: spec.serviceId,
|
|
step: "runtime-secret-preflight",
|
|
error: "runtime-secret-presence-missing",
|
|
environment: options.environment ?? "prod",
|
|
envFile: runtime.envFile,
|
|
requirements: secretPreflight.requirements,
|
|
missing: secretPreflight.missing,
|
|
secretSource: runtimeSecrets?.secretSource,
|
|
requiredSecretsPresent: runtimeSecrets?.requiredSecretsPresent ?? false,
|
|
missingSecretKeys: runtimeSecrets?.missingSecretKeys ?? secretPreflight.missing.map((item) => item.sourceEnvName),
|
|
recommendedAction: runtimeSecrets?.recommendedAction ?? "Restore the required runtime secrets in the canonical Compose env file, then rerun artifact deploy.",
|
|
runtimeSecrets,
|
|
valuesLogged: false,
|
|
recoveryHint: target.compose.authHealthGate?.recoveryHint ?? "Restore the required runtime secrets in the canonical Compose env file, then rerun artifact deploy.",
|
|
};
|
|
}
|
|
const health = runReadonlyStatus(options, true);
|
|
if (health.ok !== true) {
|
|
return { ok: false, serviceId: spec.serviceId, error: "D601 artifact registry is not healthy", health };
|
|
}
|
|
const sourceImage = artifactImageRef(options, spec, commit);
|
|
const sourceRepo = sourceRepoFor(options, spec);
|
|
const composeImage = options.targetImage ?? target.targetImage;
|
|
const commitImage = `${composeImage}:${commit}`;
|
|
const registryProbe = runRemoteScript(options, registryArtifactProbeScript(options, spec, commit), Math.max(options.timeoutMs, 120_000));
|
|
if (registryProbe.exitCode !== 0 || registryProbe.timedOut) {
|
|
return {
|
|
ok: false,
|
|
serviceId: spec.serviceId,
|
|
step: "registry-artifact-check",
|
|
error: registryArtifactMissingMessage(spec),
|
|
sourceImage,
|
|
registryProbe: commandTail(registryProbe),
|
|
};
|
|
}
|
|
const pull = await pullArtifactFromD601(options, sourceImage);
|
|
if (pull.exitCode !== 0 || pull.timedOut) {
|
|
return {
|
|
ok: false,
|
|
serviceId: spec.serviceId,
|
|
step: "docker-load",
|
|
sourceImage,
|
|
registryProbe: commandTail(registryProbe),
|
|
pull: commandTail(pull),
|
|
};
|
|
}
|
|
const localLoadedImage = sourceImage;
|
|
const labelFailure = verifyLocalArtifactLabels(localLoadedImage, spec, commit, sourceRepo);
|
|
if (labelFailure !== null) return { ...labelFailure, registryProbe: commandTail(registryProbe) };
|
|
const tag = runCommand(["docker", "tag", localLoadedImage, composeImage], repoRoot);
|
|
if (tag.exitCode !== 0 || tag.timedOut) {
|
|
return { ok: false, step: "docker-tag", targetImage: composeImage, tag: commandTail(tag), registryProbe: commandTail(registryProbe) };
|
|
}
|
|
const tagCommit = runCommand(["docker", "tag", localLoadedImage, commitImage], repoRoot);
|
|
if (tagCommit.exitCode !== 0 || tagCommit.timedOut) {
|
|
return { ok: false, step: "docker-tag-commit", targetImage: commitImage, tag: commandTail(tagCommit), registryProbe: commandTail(registryProbe) };
|
|
}
|
|
upsertEnvFileValues(runtime.envFile, {
|
|
...composeArtifactEnvValues(spec, target, options, commit),
|
|
});
|
|
const compose = runtime.command;
|
|
const composeProject = runtime.project;
|
|
const serviceName = target.compose.serviceName;
|
|
const containerName = target.compose.containerName;
|
|
const serviceLogPrefix = spec.serviceId.replace(/-/gu, "_");
|
|
const upScript = [
|
|
"set -euo pipefail",
|
|
`echo ${shellQuote(`${serviceLogPrefix}_artifact_cd source=${sourceImage} target=${composeImage}`)}`,
|
|
`${compose.map(shellQuote).join(" ")} up -d --no-build --no-deps --force-recreate ${shellQuote(serviceName)}`,
|
|
"ready=0",
|
|
"for attempt in $(seq 1 60); do",
|
|
` 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 || true)`,
|
|
" if [ -n \"$cid\" ]; then",
|
|
" health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \"$cid\" 2>/dev/null || true)",
|
|
` echo "${serviceLogPrefix}_container_probe attempt=$attempt cid=$cid health=$health"`,
|
|
" if [ \"$health\" = \"healthy\" ] || [ \"$health\" = \"running\" ]; then ready=1; break; fi",
|
|
" else",
|
|
` echo "${serviceLogPrefix}_container_probe attempt=$attempt cid=missing"`,
|
|
" fi",
|
|
" sleep 1",
|
|
"done",
|
|
"test \"$ready\" = \"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\")",
|
|
"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_service" = ${shellQuote(spec.serviceId)}`,
|
|
`test "$actual_repo" = ${shellQuote(sourceRepo)}`,
|
|
`health_json=/tmp/unidesk-${safeName(spec.serviceId)}-health.json`,
|
|
`docker exec "$cid" sh -lc ${shellQuote(target.compose.healthProbeCommand)} > "$health_json"`,
|
|
...syntheticComposeHealthDeployScript(target.compose, commit, sourceRepo),
|
|
"cat \"$health_json\"",
|
|
...authHealthGateScript(target.compose.authHealthGate, "\"$health_json\""),
|
|
...(target.compose.requireHealthCommit ? [
|
|
"health_commit=$(python3 -c 'import json,sys; print(((json.load(open(sys.argv[1])).get(\"deploy\") or {}).get(\"commit\") or \"\"))' \"$health_json\")",
|
|
"health_requested_commit=$(python3 -c 'import json,sys; print(((json.load(open(sys.argv[1])).get(\"deploy\") or {}).get(\"requestedCommit\") or \"\"))' \"$health_json\")",
|
|
`test "$health_commit" = ${shellQuote(commit)}`,
|
|
`test "$health_requested_commit" = ${shellQuote(commit)}`,
|
|
] : []),
|
|
].join("\n");
|
|
const deploy = runCommand(["bash", "-lc", composeLockScript(upScript)], repoRoot, { timeoutMs: Math.max(options.timeoutMs, 300_000) });
|
|
if (deploy.exitCode !== 0 || deploy.timedOut) {
|
|
return {
|
|
ok: false,
|
|
serviceId: spec.serviceId,
|
|
step: "compose-recreate",
|
|
sourceImage,
|
|
targetImage: composeImage,
|
|
registryProbe: commandTail(registryProbe),
|
|
runtimeSecretPreflight: secretPreflight,
|
|
deploy: commandTail(deploy),
|
|
recoveryHint: target.compose.authHealthGate?.recoveryHint ?? undefined,
|
|
};
|
|
}
|
|
const running = runCommand(["docker", "inspect", containerName, "--format", "image={{.Config.Image}} imageId={{.Image}} status={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}"], repoRoot);
|
|
return {
|
|
ok: true,
|
|
supported: true,
|
|
serviceId: spec.serviceId,
|
|
commit,
|
|
providerId: options.providerId,
|
|
sourceImage,
|
|
registryProbe: commandTail(registryProbe),
|
|
localLoadedImage,
|
|
targetImage: composeImage,
|
|
targetCommitImage: commitImage,
|
|
pull: commandTail(pull),
|
|
deploy: commandTail(deploy),
|
|
running: running.stdout.trim(),
|
|
validation: {
|
|
liveCommit: commit,
|
|
liveRequestedCommit: target.compose.requireHealthCommit ? commit : "not-required",
|
|
imageLabelCommit: commit,
|
|
serviceHealthCommit: 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,
|
|
runtimeSecrets,
|
|
authHealthGate: target.compose.authHealthGate === undefined ? "not-required" : {
|
|
requiredFields: target.compose.authHealthGate.requiredFields,
|
|
passed: true,
|
|
},
|
|
healthyOldVersionAccepted: false,
|
|
},
|
|
rollback: {
|
|
previousImageHint: `Use docker image ls and docker inspect logs for the previous ${spec.serviceId} image id; Compose named volumes were not changed.`,
|
|
commandShape: `${compose.map(shellQuote).join(" ")} up -d --no-build --no-deps --force-recreate ${serviceName}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function deployBackendCoreNow(options: ArtifactRegistryOptions): Promise<Record<string, unknown>> {
|
|
if (options.commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit <full-sha>");
|
|
const spec = artifactConsumerSpecs["backend-core"];
|
|
const target = artifactConsumerTarget(spec, options.environment);
|
|
if (target === null) return unsupportedEnvironment(spec, options);
|
|
return deployComposeArtifactNow(options, spec, target);
|
|
}
|
|
|
|
export function d601ComposeArtifactDeployScript(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget, commit: string): string {
|
|
if (target.compose === undefined) throw new Error(`${spec.serviceId} missing compose artifact consumer config`);
|
|
const compose = target.compose;
|
|
const sourceImage = artifactImageRef(options, spec, commit);
|
|
const sourceRepo = sourceRepoFor(options, spec);
|
|
const commitImage = target.targetCommitImage(commit);
|
|
const envValues = composeArtifactEnvValues(spec, target, options, commit);
|
|
const labels = {
|
|
"unidesk.ai/deploy-service-id": spec.serviceId,
|
|
"unidesk.ai/deploy-ref": deployRefFor(options, spec),
|
|
"unidesk.ai/deploy-repo": sourceRepo,
|
|
"unidesk.ai/deploy-commit": commit,
|
|
"unidesk.ai/deploy-requested-commit": commit,
|
|
"unidesk.ai/image-source": sourceImage,
|
|
"unidesk.ai/deploy-environment": options.environment ?? "prod",
|
|
};
|
|
const override = {
|
|
services: {
|
|
[compose.serviceName]: {
|
|
image: "${UNIDESK_ARTIFACT_STABLE_IMAGE}",
|
|
labels,
|
|
environment: {
|
|
UNIDESK_DEPLOY_SERVICE_ID: spec.serviceId,
|
|
UNIDESK_DEPLOY_REF: deployRefFor(options, spec),
|
|
UNIDESK_DEPLOY_REPO: sourceRepo,
|
|
UNIDESK_DEPLOY_COMMIT: commit,
|
|
UNIDESK_DEPLOY_REQUESTED_COMMIT: commit,
|
|
...envValues,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
return [
|
|
"set -euo pipefail",
|
|
`registry_image=${shellQuote(sourceImage)}`,
|
|
`stable_image=${shellQuote(target.targetImage)}`,
|
|
`commit_image=${shellQuote(commitImage)}`,
|
|
`service_id=${shellQuote(spec.serviceId)}`,
|
|
`source_repo=${shellQuote(sourceRepo)}`,
|
|
`deploy_ref=${shellQuote(deployRefFor(options, spec))}`,
|
|
`commit=${shellQuote(commit)}`,
|
|
`dockerfile=${shellQuote(spec.dockerfile)}`,
|
|
`work_dir=${shellQuote(compose.workDir ?? "/home/ubuntu")}`,
|
|
`compose_file=${shellQuote(compose.composeFile ?? "docker-compose.yml")}`,
|
|
`compose_service=${shellQuote(compose.serviceName)}`,
|
|
`container=${shellQuote(compose.containerName)}`,
|
|
`project_hint=${shellQuote(compose.projectHint ?? "")}`,
|
|
`health_probe_b64=${shellQuote(base64(compose.healthProbeCommand))}`,
|
|
`override_b64=${shellQuote(base64(JSON.stringify(override, null, 2)))}`,
|
|
"command -v docker >/dev/null",
|
|
"docker compose version >/dev/null",
|
|
`curl -fsSI -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' ${shellQuote(`http://127.0.0.1:${options.port}/v2/${spec.registryRepository}/manifests/${commit}`)} >/dev/null`,
|
|
"docker pull -q \"$registry_image\" >/dev/null",
|
|
"label_commit=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}')",
|
|
"label_service=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/service-id\" }}')",
|
|
"label_repo=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/source-repo\" }}')",
|
|
"label_dockerfile=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/dockerfile\" }}')",
|
|
"test \"$label_commit\" = \"$commit\"",
|
|
"test \"$label_service\" = \"$service_id\"",
|
|
"test \"$label_repo\" = \"$source_repo\"",
|
|
"test \"$label_dockerfile\" = \"$dockerfile\"",
|
|
"test -d \"$work_dir\"",
|
|
"test -f \"$work_dir/$compose_file\"",
|
|
"docker tag \"$registry_image\" \"$stable_image\"",
|
|
"docker tag \"$registry_image\" \"$commit_image\"",
|
|
"override=\"$work_dir/.unidesk-artifact-consumer.override.yml\"",
|
|
"printf '%s' \"$override_b64\" | base64 -d > \"$override\"",
|
|
"export UNIDESK_ARTIFACT_STABLE_IMAGE=\"$stable_image\"",
|
|
"project=$(docker inspect \"$container\" --format '{{ index .Config.Labels \"com.docker.compose.project\" }}' 2>/dev/null || true)",
|
|
"if [ -z \"$project\" ]; then project=\"$project_hint\"; fi",
|
|
"if [ -z \"$project\" ]; then project=$(basename \"$work_dir\"); fi",
|
|
"cd \"$work_dir\"",
|
|
"docker compose -p \"$project\" -f \"$compose_file\" -f \"$override\" up -d --no-build --no-deps --force-recreate \"$compose_service\"",
|
|
"ready=0",
|
|
"for attempt in $(seq 1 90); do",
|
|
" cid=$(docker ps -q --filter label=com.docker.compose.project=\"$project\" --filter label=com.docker.compose.service=\"$compose_service\" --filter label=com.docker.compose.oneoff=False | head -1 || true)",
|
|
" if [ -n \"$cid\" ]; then",
|
|
" health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \"$cid\" 2>/dev/null || true)",
|
|
" echo \"artifact_cd_container_probe attempt=$attempt cid=$cid health=$health\"",
|
|
" if [ \"$health\" = \"healthy\" ] || [ \"$health\" = \"running\" ]; then ready=1; break; fi",
|
|
" else",
|
|
" echo \"artifact_cd_container_probe attempt=$attempt cid=missing\"",
|
|
" fi",
|
|
" sleep 2",
|
|
"done",
|
|
"test \"$ready\" = \"1\"",
|
|
"cid=$(docker ps -q --filter label=com.docker.compose.project=\"$project\" --filter label=com.docker.compose.service=\"$compose_service\" --filter label=com.docker.compose.oneoff=False | head -1)",
|
|
"image_id=$(docker inspect -f '{{.Image}}' \"$cid\")",
|
|
"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\")",
|
|
"container_commit=$(docker inspect -f '{{ index .Config.Labels \"unidesk.ai/deploy-commit\" }}' \"$cid\")",
|
|
"test \"$actual_commit\" = \"$commit\"",
|
|
"test \"$actual_service\" = \"$service_id\"",
|
|
"test \"$container_commit\" = \"$commit\"",
|
|
"health_probe=$(printf '%s' \"$health_probe_b64\" | base64 -d)",
|
|
"docker exec \"$cid\" sh -lc \"$health_probe\" >/tmp/unidesk-artifact-health.out",
|
|
"cat /tmp/unidesk-artifact-health.out",
|
|
...authHealthGateScript(compose.authHealthGate, shellQuote("/tmp/unidesk-artifact-health.out")),
|
|
"printf 'artifact_cd_service=%s\\nartifact_cd_source_repo=%s\\nartifact_cd_deploy_ref=%s\\nartifact_cd_source_image=%s\\nartifact_cd_stable_image=%s\\nartifact_cd_runtime_image=%s\\nartifact_cd_commit=%s\\nartifact_cd_container=%s\\nartifact_cd_container_id=%s\\nartifact_cd_image_label_commit=%s\\nartifact_cd_container_label_commit=%s\\n' \"$service_id\" \"$source_repo\" \"$deploy_ref\" \"$registry_image\" \"$stable_image\" \"$commit_image\" \"$commit\" \"$container\" \"$cid\" \"$actual_commit\" \"$container_commit\"",
|
|
].join("\n");
|
|
}
|
|
|
|
export async function deployD601ComposeArtifactNow(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget): Promise<Record<string, unknown>> {
|
|
const environment = options.environment ?? "prod";
|
|
const commit = options.commit;
|
|
if (commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
|
|
if (target.compose === undefined) throw new Error(`${spec.serviceId} missing compose artifact consumer config`);
|
|
const health = runReadonlyStatus(options, true);
|
|
if (health.ok !== true) return { ok: false, serviceId: spec.serviceId, error: "D601 artifact registry is not healthy", health };
|
|
const sourceImage = artifactImageRef(options, spec, commit);
|
|
const registryProbe = runRemoteScript(options, registryArtifactProbeScript(options, spec, commit), Math.max(options.timeoutMs, 120_000));
|
|
if (registryProbe.exitCode !== 0 || registryProbe.timedOut) {
|
|
return {
|
|
ok: false,
|
|
supported: true,
|
|
serviceId: spec.serviceId,
|
|
step: "registry-artifact-check",
|
|
error: registryArtifactMissingMessage(spec),
|
|
sourceImage,
|
|
registryProbe: commandTail(registryProbe),
|
|
};
|
|
}
|
|
const deploy = runRemoteScript(options, d601ComposeArtifactDeployScript(options, spec, target, commit), Math.max(options.timeoutMs, 420_000));
|
|
if (deploy.exitCode !== 0 || deploy.timedOut) {
|
|
return {
|
|
ok: false,
|
|
supported: true,
|
|
serviceId: spec.serviceId,
|
|
step: "d601-compose-artifact-deploy",
|
|
sourceImage,
|
|
registryProbe: commandTail(registryProbe),
|
|
deploy: commandTail(deploy),
|
|
rollback: rollbackInfo(spec, target, environment, commit),
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
supported: true,
|
|
serviceId: spec.serviceId,
|
|
environment,
|
|
commit,
|
|
providerId: options.providerId,
|
|
sourceRepo: sourceRepoFor(options, spec),
|
|
deployRef: deployRefFor(options, spec),
|
|
sourceImage,
|
|
targetImage: target.targetImage,
|
|
targetCommitImage: target.targetCommitImage(commit),
|
|
composeService: target.compose.serviceName,
|
|
containerName: target.compose.containerName,
|
|
registryProbe: commandTail(registryProbe),
|
|
deploy: commandTail(deploy),
|
|
validation: {
|
|
liveCommit: commit,
|
|
liveRequestedCommit: commit,
|
|
imageLabelCommit: commit,
|
|
containerDeployLabelCommit: commit,
|
|
serviceHealthCommit: target.compose.requireHealthCommit ? commit : "not-required",
|
|
serviceHealthRequestedCommit: target.compose.requireHealthCommit ? commit : "not-required",
|
|
healthyOldVersionAccepted: false,
|
|
},
|
|
rollback: rollbackInfo(spec, target, environment, commit),
|
|
};
|
|
}
|
|
|
|
export function deployBackendCoreJob(args: string[], options: ArtifactRegistryOptions): Record<string, unknown> {
|
|
if (options.commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit <full-sha>");
|
|
const spec = artifactConsumerSpecs["backend-core"];
|
|
const target = artifactConsumerTarget(spec, options.environment);
|
|
if (target === null) return unsupportedEnvironment(spec, options);
|
|
const runArgs = args.includes("--run-now") ? args : [...args, "--run-now"];
|
|
const command = [process.execPath, rootPath("scripts", "cli.ts"), "artifact-registry", ...runArgs];
|
|
const job = startJob("artifact_registry_backend_core_cd", command, `Pull and deploy backend-core artifact ${options.commit} from D601 registry`);
|
|
return {
|
|
ok: true,
|
|
mode: "async-job",
|
|
job,
|
|
statusCommand: `bun scripts/cli.ts job status ${job.id}`,
|
|
tailCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 30000`,
|
|
note: "Backend-core CD continues in the background: D601 registry health, provider-gateway SSH image stream, docker load, retag, no-build recreate, live commit verification.",
|
|
};
|
|
}
|
|
|
|
export function legacyDeployBackendCoreResult(options: ArtifactRegistryOptions): Record<string, unknown> {
|
|
return {
|
|
ok: false,
|
|
supported: false,
|
|
deprecated: true,
|
|
action: "deploy-backend-core",
|
|
replacement: "bun scripts/cli.ts deploy apply --env prod --service backend-core --commit <full-sha>",
|
|
serviceId: "backend-core",
|
|
environment: options.environment ?? "prod",
|
|
commit: options.commit,
|
|
policy: "backend-core production CD must enter through deploy apply --env prod so the standard artifact-consumer guardrails run before any mutation; the legacy artifact-registry deploy-backend-core entry is retained only as a documented compatibility name.",
|
|
};
|
|
}
|
|
|
|
export 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 (deployJsonService !== null && drifts.length > 0) return deployJsonDriftResult(deployJsonService, environment, drifts);
|
|
const verificationBlocked = spec.runtimeVerification === "blocked";
|
|
const livePolicy = artifactConsumerLivePolicy(spec, environment);
|
|
const liveReason = artifactConsumerLiveBlockReason(spec, environment);
|
|
const requiresSupervisorApproval = livePolicy === "supervisor-only" || spec.serviceId === "code-queue";
|
|
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: 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 }),
|
|
];
|
|
const common = {
|
|
ok: !verificationBlocked,
|
|
supported: !verificationBlocked,
|
|
dryRun: true,
|
|
mutation: false,
|
|
error: verificationBlocked ? "runtime-verification-blocked" : undefined,
|
|
environment,
|
|
providerId: effectiveOptions.providerId,
|
|
artifactRegistryTarget: artifactRegistryOptionsTargetSummary(effectiveOptions),
|
|
serviceId: spec.serviceId,
|
|
commit,
|
|
sourceRepo: sourceRepoFor(effectiveOptions, spec),
|
|
deployRef: deployRefFor(effectiveOptions, spec),
|
|
sourceImage,
|
|
source: {
|
|
repo: sourceRepoFor(effectiveOptions, spec),
|
|
commit,
|
|
dockerfile: spec.dockerfile,
|
|
},
|
|
registry: {
|
|
endpoint: registryEndpoint,
|
|
repository: registryRepositoryFor(effectiveOptions, spec),
|
|
tag: commit,
|
|
imageRef: sourceImage,
|
|
digest: null,
|
|
digestHeader: "Docker-Content-Digest",
|
|
digestSource: "planned registry manifest HEAD; live apply must read this digest before mutation",
|
|
},
|
|
build: {
|
|
willCompile: false,
|
|
willRunCargoBuild: false,
|
|
willRunDockerBuild: false,
|
|
willRunDockerComposeBuild: false,
|
|
producerBoundary: spec.serviceId === "backend-core" ? "ci publish-backend-core" : "ci publish-user-service",
|
|
},
|
|
requiredLabels: {
|
|
"unidesk.ai/service-id": spec.serviceId,
|
|
"unidesk.ai/source-repo": sourceRepoFor(effectiveOptions, spec),
|
|
"unidesk.ai/source-commit": commit,
|
|
"unidesk.ai/dockerfile": spec.dockerfile,
|
|
},
|
|
registryProbe: {
|
|
method: "HEAD",
|
|
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`,
|
|
liveApply: {
|
|
policy: livePolicy,
|
|
allowed: !verificationBlocked && livePolicy === "enabled",
|
|
requiresSupervisorApproval,
|
|
reason: liveReason,
|
|
},
|
|
requiresSupervisorApproval,
|
|
selfBootstrapGuard: artifactConsumerSelfBootstrapGuard(spec, environment, requiresSupervisorApproval),
|
|
affectedRuntime: spec.serviceId === "code-queue" ? codeQueueAffectedRuntime(environment, true) : undefined,
|
|
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`);
|
|
const runtimeSecretsRaw = runtimeSecretContractForComposeTarget(config, target, target.compose.requiredRuntimeSecrets);
|
|
const runtimeSecrets = runtimeSecretsRaw === undefined ? undefined : runtimeSecretRecommendedActionForService(runtimeSecretsRaw, environment, spec.serviceId);
|
|
return {
|
|
...common,
|
|
target: {
|
|
kind: spec.kind,
|
|
runtimeHost: spec.kind === "d601-compose" ? "D601" : "main-server",
|
|
workDir: target.compose.workDir,
|
|
composeFile: target.compose.composeFile,
|
|
composeService: target.compose.serviceName,
|
|
containerName: target.compose.containerName,
|
|
targetImage: options.targetImage ?? target.targetImage,
|
|
runtimeImage: target.targetCommitImage(commit),
|
|
deployEnvPrefix: target.compose.deployEnvPrefix,
|
|
deployCommandShape: `docker compose up -d --no-build --no-deps --force-recreate ${target.compose.serviceName}`,
|
|
},
|
|
excludedTargets: spec.serviceId === "code-queue-mgr"
|
|
? [
|
|
{
|
|
serviceId: "code-queue",
|
|
reason: "code-queue-mgr is the main-server control-plane sidecar only; scheduler/runner live in the separate Code Queue execution plane and are not part of this artifact consumer.",
|
|
},
|
|
{
|
|
services: ["code-queue-scheduler", "code-queue-runner"],
|
|
reason: "dry-run does not mutate D601 Code Queue scheduler, runner, tasks, interrupts, or cancellations.",
|
|
},
|
|
]
|
|
: spec.serviceId === "code-queue"
|
|
? codeQueueExcludedTargets(environment)
|
|
: undefined,
|
|
validation: [
|
|
"D601 registry /v2 manifest exists for the commit tag",
|
|
spec.kind === "d601-compose"
|
|
? "D601-pulled image labels match service id, source repo, source commit, and Dockerfile"
|
|
: "loaded image labels match service id, source repo, source commit, and Dockerfile",
|
|
spec.kind === "d601-compose"
|
|
? "running D601 Compose container is recreated with a no-build override that points the service image at the artifact"
|
|
: "running Compose container image label matches the requested commit",
|
|
...(spec.kind === "d601-compose" ? ["running Compose container image label matches the requested commit"] : []),
|
|
verificationBlocked
|
|
? `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
|
|
? `${spec.serviceId} runtime health probe succeeds and reports deploy.commit/deploy.requestedCommit matching the artifact commit`
|
|
: `${spec.serviceId} /health succeeds for the recreated container`,
|
|
...(target.compose.requiredRuntimeSecrets === undefined ? [] : [
|
|
`${spec.serviceId} live apply checks required runtime secret presence in the canonical Compose env file without reading or printing values`,
|
|
]),
|
|
...(target.compose.authHealthGate === undefined ? [] : [
|
|
`${spec.serviceId} live apply gates success on /health.auth fields: ${target.compose.authHealthGate.requiredFields.join(", ")}`,
|
|
]),
|
|
],
|
|
runtimeSecrets,
|
|
authHealthGate: target.compose.authHealthGate === undefined ? undefined : {
|
|
path: "/health",
|
|
requiredAuthFields: target.compose.authHealthGate.requiredFields,
|
|
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),
|
|
};
|
|
}
|
|
return {
|
|
...common,
|
|
target: {
|
|
kind: "d601-k3s",
|
|
namespace: contractTarget?.namespace ?? target.k3s?.namespace,
|
|
deployment: contractTarget?.deployment ?? target.k3s?.deploymentName,
|
|
deployments: k3sDeployments,
|
|
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,
|
|
},
|
|
excludedTargets: spec.serviceId === "code-queue" ? codeQueueExcludedTargets(environment) : undefined,
|
|
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",
|
|
"native k3s containerd has the commit image and stable runtime image tag",
|
|
"Deployment annotation and pod image id label match the requested commit",
|
|
"service health via Kubernetes API service proxy returns the same deploy.commit and deploy.requestedCommit",
|
|
...(environment === "dev" && spec.serviceId === "frontend" ? ["dev frontend auth/session config is synced from main-server config before rollout"] : []),
|
|
],
|
|
rollback: rollbackInfo(spec, target, environment, commit),
|
|
};
|
|
}
|