refactor: split control-plane cli modules
This commit is contained in:
@@ -0,0 +1,610 @@
|
||||
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. compose-deploy module for scripts/src/artifact-registry.ts.
|
||||
|
||||
// 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 type { ArtifactConsumerSpec, ArtifactConsumerTarget, 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,
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user