standardize user-service artifact CD

This commit is contained in:
Codex
2026-05-19 10:07:25 +00:00
parent 292d0cee60
commit 1e326cb8fc
5 changed files with 591 additions and 50 deletions
+451 -36
View File
@@ -5,7 +5,7 @@ import { readConfig, repoRoot, rootPath } from "./config";
import { resolveComposeCommand, writeComposeEnv } from "./docker";
import { startJob } from "./jobs";
type ArtifactRegistryAction = "plan" | "render" | "status" | "health" | "install" | "deploy-backend-core";
type ArtifactRegistryAction = "plan" | "render" | "status" | "health" | "install" | "deploy-backend-core" | "deploy-service";
interface ArtifactRegistryOptions {
providerId: string;
@@ -23,6 +23,8 @@ interface ArtifactRegistryOptions {
runNow: boolean;
commit: string | null;
targetImage: string;
serviceId: string | null;
sourceRepo: string;
}
interface RenderedFile {
@@ -59,6 +61,59 @@ const defaultOptions: ArtifactRegistryOptions = {
runNow: false,
commit: null,
targetImage: "unidesk-backend-core",
serviceId: null,
sourceRepo: "https://github.com/pikasTech/unidesk",
};
const supportedArtifactConsumerServices = ["backend-core", "decision-center"] as const;
type SupportedArtifactConsumerService = typeof supportedArtifactConsumerServices[number];
interface ArtifactConsumerSpec {
serviceId: SupportedArtifactConsumerService;
kind: "compose" | "d601-k3s";
registryRepository: string;
dockerfile: string;
targetImage: string;
targetCommitImage: (commit: string) => string;
deployRef: string;
k3s?: {
namespace: string;
manifestPath: string;
deploymentName: string;
serviceName: string;
servicePort: number;
containerName: string;
healthPath: string;
};
}
const artifactConsumerSpecs: Record<SupportedArtifactConsumerService, ArtifactConsumerSpec> = {
"backend-core": {
serviceId: "backend-core",
kind: "compose",
registryRepository: "unidesk/backend-core",
dockerfile: "src/components/backend-core/Dockerfile",
targetImage: "unidesk-backend-core",
targetCommitImage: (commit: string) => `unidesk-backend-core:${commit}`,
deployRef: "deploy.json#environments.prod.services.backend-core",
},
"decision-center": {
serviceId: "decision-center",
kind: "d601-k3s",
registryRepository: "unidesk/decision-center",
dockerfile: "src/components/microservices/decision-center/Dockerfile",
targetImage: "unidesk-decision-center:d601",
targetCommitImage: (commit: string) => `unidesk-decision-center:${commit}`,
deployRef: "deploy.json#environments.prod.services.decision-center",
k3s: {
namespace: "unidesk",
manifestPath: "/home/ubuntu/cq-deploy/src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml",
deploymentName: "decision-center",
serviceName: "decision-center",
servicePort: 4277,
containerName: "decision-center",
healthPath: "/health",
},
},
};
function isHelpArg(value: string | undefined): boolean {
@@ -124,6 +179,12 @@ function parseOptions(args: string[]): ArtifactRegistryOptions {
} else if (arg === "--target-image") {
options.targetImage = requireValue(args, index, arg);
index += 1;
} else if (arg === "--service" || arg === "--service-id") {
options.serviceId = requireValue(args, index, arg);
index += 1;
} else if (arg === "--source-repo") {
options.sourceRepo = requireValue(args, index, arg);
index += 1;
} else {
throw new Error(`unknown artifact-registry option: ${arg}`);
}
@@ -389,6 +450,27 @@ function commandTail(result: CommandResult): Record<string, unknown> {
};
}
function artifactConsumerSpec(serviceId: string): ArtifactConsumerSpec | null {
return (artifactConsumerSpecs as Record<string, ArtifactConsumerSpec | undefined>)[serviceId] ?? null;
}
function unsupportedService(serviceId: string, options: ArtifactRegistryOptions): Record<string, unknown> {
return {
ok: false,
supported: false,
error: "unsupported",
serviceId,
providerId: options.providerId,
reason: "No standardized D601 registry CD consumer is implemented for this service.",
supportedServices: supportedArtifactConsumerServices,
policy: "unsupported services must not silently fall back to maintenance-channel source builds or legacy direct deployment",
};
}
function artifactImageRef(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, commit: string): string {
return `127.0.0.1:${options.port}/${spec.registryRepository}:${commit}`;
}
function runRemoteScript(options: ArtifactRegistryOptions, script: string, timeoutMs = options.timeoutMs): CommandResult {
const command = [process.execPath, "scripts/cli.ts", "ssh", options.providerId, "argv", "bash", "-lc", script];
return runCommand(command, repoRoot, { timeoutMs });
@@ -587,7 +669,7 @@ function upsertEnvFileValues(path: string, values: Record<string, string>): void
writeFileSync(path, `${lines.join("\n")}\n`, "utf8");
}
function pullBackendCoreArtifactFromD601(options: ArtifactRegistryOptions, sourceImage: string): CommandResult {
function pullArtifactFromD601(options: ArtifactRegistryOptions, sourceImage: string): CommandResult {
const remoteScript = [
"set -euo pipefail",
`image=${shellQuote(sourceImage)}`,
@@ -595,7 +677,7 @@ function pullBackendCoreArtifactFromD601(options: ArtifactRegistryOptions, sourc
"trap 'rm -rf \"$DOCKER_CONFIG\"' EXIT",
"printf '{}\\n' > \"$DOCKER_CONFIG/config.json\"",
"docker pull -q \"$image\" >/dev/null",
"docker image inspect \"$image\" --format 'remote_source={{ index .Config.Labels \"unidesk.ai/source-commit\" }} remote_service={{ index .Config.Labels \"unidesk.ai/service-id\" }}' >&2",
"docker image inspect \"$image\" --format 'remote_source={{ index .Config.Labels \"unidesk.ai/source-commit\" }} remote_service={{ index .Config.Labels \"unidesk.ai/service-id\" }} remote_dockerfile={{ index .Config.Labels \"unidesk.ai/dockerfile\" }}' >&2",
"docker save \"$image\" | gzip -1",
].join("\n");
const sshCommand = [
@@ -615,37 +697,73 @@ function pullBackendCoreArtifactFromD601(options: ArtifactRegistryOptions, sourc
return runCommand(["bash", "-lc", pipeline], repoRoot, { timeoutMs: Math.max(options.timeoutMs, 900_000) });
}
async function deployBackendCoreNow(options: ArtifactRegistryOptions): Promise<Record<string, unknown>> {
const commit = options.commit;
if (commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit <full-sha>");
const health = runReadonlyStatus(options, true);
if (health.ok !== true) {
return { ok: false, error: "D601 artifact registry is not healthy", health };
}
const sourceImage = `127.0.0.1:${options.port}/unidesk/backend-core:${commit}`;
const composeImage = options.targetImage;
const commitImage = `${options.targetImage}:${commit}`;
const registryProbeScript = [
function registryArtifactProbeScript(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, commit: string): string {
const sourceImage = artifactImageRef(options, spec, commit);
return [
"set -euo pipefail",
`registry_image=${shellQuote(sourceImage)}`,
`manifest_url=${shellQuote(`http://127.0.0.1:${options.port}/v2/unidesk/backend-core/manifests/${commit}`)}`,
"headers=$(mktemp /tmp/unidesk-backend-core-manifest.XXXXXX.headers)",
`manifest_url=${shellQuote(`http://127.0.0.1:${options.port}/v2/${spec.registryRepository}/manifests/${commit}`)}`,
"headers=$(mktemp /tmp/unidesk-artifact-manifest.XXXXXX.headers)",
"trap 'rm -f \"$headers\"' EXIT",
"curl -fsSI -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' -D \"$headers\" -o /dev/null \"$manifest_url\"",
"manifest_digest=$(awk 'BEGIN{IGNORECASE=1} /^Docker-Content-Digest:/ {gsub(/\\r/, \"\", $2); print $2; exit}' \"$headers\")",
"printf 'registry_image=%s\\nmanifest_url=%s\\nmanifest_digest=%s\\n' \"$registry_image\" \"$manifest_url\" \"$manifest_digest\"",
].join("\n");
const registryProbe = runRemoteScript(options, registryProbeScript, Math.max(options.timeoutMs, 120_000));
}
function registryArtifactMissingMessage(spec: ArtifactConsumerSpec): string {
return `${spec.serviceId} image artifact is missing from D601 registry; run CI artifact publication first`;
}
function verifyLocalArtifactLabels(
localLoadedImage: string,
spec: ArtifactConsumerSpec,
commit: string,
): Record<string, unknown> | null {
const inspectPulled = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{json .Config.Labels}}"], repoRoot);
const labelCommit = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/source-commit\" }}"], repoRoot);
const labelService = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/service-id\" }}"], repoRoot);
const labelDockerfile = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/dockerfile\" }}"], repoRoot);
const observed = {
commit: labelCommit.stdout.trim(),
serviceId: labelService.stdout.trim(),
dockerfile: labelDockerfile.stdout.trim(),
labels: inspectPulled.stdout.trim(),
};
const ok = observed.commit === commit
&& observed.serviceId === spec.serviceId
&& observed.dockerfile === spec.dockerfile;
if (ok) return null;
return {
ok: false,
step: "image-label-verify",
expected: { commit, serviceId: spec.serviceId, dockerfile: spec.dockerfile },
observed,
};
}
async function deployBackendCoreNow(options: ArtifactRegistryOptions): Promise<Record<string, unknown>> {
const commit = options.commit;
if (commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit <full-sha>");
const spec = artifactConsumerSpecs["backend-core"];
const health = runReadonlyStatus(options, true);
if (health.ok !== true) {
return { ok: false, error: "D601 artifact registry is not healthy", health };
}
const sourceImage = artifactImageRef(options, spec, commit);
const composeImage = options.targetImage === defaultOptions.targetImage ? spec.targetImage : options.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,
step: "registry-artifact-check",
error: "backend-core image artifact is missing from D601 registry; run CI artifact publication first",
error: registryArtifactMissingMessage(spec),
sourceImage,
registryProbe: commandTail(registryProbe),
};
}
const pull = pullBackendCoreArtifactFromD601(options, sourceImage);
const pull = pullArtifactFromD601(options, sourceImage);
if (pull.exitCode !== 0 || pull.timedOut) {
return {
ok: false,
@@ -656,18 +774,8 @@ async function deployBackendCoreNow(options: ArtifactRegistryOptions): Promise<R
};
}
const localLoadedImage = sourceImage;
const inspectPulled = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{json .Config.Labels}}"], repoRoot);
const labelCommit = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/source-commit\" }}"], repoRoot);
const labelService = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/service-id\" }}"], repoRoot);
if (labelCommit.stdout.trim() !== commit || labelService.stdout.trim() !== "backend-core") {
return {
ok: false,
step: "image-label-verify",
expected: { commit, serviceId: "backend-core" },
observed: { commit: labelCommit.stdout.trim(), serviceId: labelService.stdout.trim(), labels: inspectPulled.stdout.trim() },
registryProbe: commandTail(registryProbe),
};
}
const labelFailure = verifyLocalArtifactLabels(localLoadedImage, spec, commit);
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) };
@@ -679,9 +787,9 @@ async function deployBackendCoreNow(options: ArtifactRegistryOptions): Promise<R
const config = readConfig();
const runtimeEnv = writeComposeEnv(config, false);
upsertEnvFileValues(runtimeEnv.envFile, {
UNIDESK_DEPLOY_REF: "deploy.json#environments.prod.services.backend-core",
UNIDESK_DEPLOY_REF: spec.deployRef,
UNIDESK_DEPLOY_SERVICE_ID: "backend-core",
UNIDESK_DEPLOY_REPO: "https://github.com/pikasTech/unidesk",
UNIDESK_DEPLOY_REPO: options.sourceRepo,
UNIDESK_DEPLOY_COMMIT: commit,
UNIDESK_DEPLOY_REQUESTED_COMMIT: commit,
});
@@ -756,10 +864,305 @@ function deployBackendCoreJob(args: string[], options: ArtifactRegistryOptions):
};
}
function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, commit: string): Record<string, unknown> {
const sourceImage = artifactImageRef(options, spec, commit);
const common = {
ok: true,
supported: true,
dryRun: true,
mutation: false,
providerId: options.providerId,
serviceId: spec.serviceId,
commit,
sourceRepo: options.sourceRepo,
sourceImage,
requiredLabels: {
"unidesk.ai/service-id": spec.serviceId,
"unidesk.ai/source-commit": commit,
"unidesk.ai/dockerfile": spec.dockerfile,
},
registryProbe: {
method: "HEAD",
url: `http://127.0.0.1:${options.port}/v2/${spec.registryRepository}/manifests/${commit}`,
},
boundary: "prod CD is artifact-consumer only: verify commit-pinned registry image, pull/import, deploy, then verify live commit/image/health; it never builds source on prod",
};
if (spec.kind === "compose") {
return {
...common,
target: {
kind: "compose",
composeService: "backend-core",
targetImage: options.targetImage === defaultOptions.targetImage ? spec.targetImage : options.targetImage,
deployCommandShape: "docker compose up -d --no-build --no-deps --force-recreate backend-core",
},
validation: [
"D601 registry /v2 manifest exists for the commit tag",
"loaded image labels match service id, source commit, and Dockerfile",
"running Compose container image label matches the requested commit",
"backend-core /health succeeds for the recreated container",
],
};
}
return {
...common,
target: {
kind: "d601-k3s",
namespace: spec.k3s?.namespace,
deployment: spec.k3s?.deploymentName,
service: spec.k3s?.serviceName,
stableImage: spec.targetImage,
runtimeImage: spec.targetCommitImage(commit),
manifestPath: spec.k3s?.manifestPath,
deployCommandShape: "kubectl set image + set env + annotate + rollout status",
},
validation: [
"D601 registry /v2 manifest exists for the commit tag before mutation",
"D601 Docker-pulled image labels match service id, 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",
],
rollback: rollbackInfo(spec, commit),
};
}
function rollbackInfo(spec: ArtifactConsumerSpec, commit: string): Record<string, unknown> {
if (spec.kind === "compose") {
return {
type: "compose-retag-recreate",
previousImageHint: "Use docker image ls / docker inspect to find the previous labeled backend-core image id; Compose volumes are unchanged.",
};
}
return {
type: "d601-k3s-previous-commit",
serviceId: spec.serviceId,
currentCommit: commit,
discovery: `kubectl -n ${spec.k3s?.namespace} rollout history deployment/${spec.k3s?.deploymentName} && kubectl -n ${spec.k3s?.namespace} get deployment ${spec.k3s?.deploymentName} -o jsonpath='{.metadata.annotations.unidesk\\.ai/deploy-previous-commit}'`,
commandShape: `bun scripts/cli.ts deploy apply --env prod --service ${spec.serviceId} --commit <previous-full-sha>`,
note: "Rollback is exposed as the same artifact consumer pointed at a previous commit-pinned image that still exists in D601 registry.",
};
}
function d601K3sArtifactDeployScript(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, commit: string): string {
if (spec.k3s === undefined) throw new Error(`${spec.serviceId} missing k3s artifact consumer config`);
const sourceImage = artifactImageRef(options, spec, commit);
const commitImage = spec.targetCommitImage(commit);
const k3s = spec.k3s;
const labels = [
`unidesk.ai/deploy-service-id=${spec.serviceId}`,
`unidesk.ai/deploy-repo=${options.sourceRepo}`,
`unidesk.ai/deploy-commit=${commit}`,
`unidesk.ai/deploy-requested-commit=${commit}`,
`unidesk.ai/image-source=${sourceImage}`,
];
return [
"set -euo pipefail",
rootExecPrelude(),
`registry_image=${shellQuote(sourceImage)}`,
`stable_image=${shellQuote(spec.targetImage)}`,
`commit_image=${shellQuote(commitImage)}`,
`service_id=${shellQuote(spec.serviceId)}`,
`source_repo=${shellQuote(options.sourceRepo)}`,
`commit=${shellQuote(commit)}`,
`dockerfile=${shellQuote(spec.dockerfile)}`,
`namespace=${shellQuote(k3s.namespace)}`,
`deployment=${shellQuote(k3s.deploymentName)}`,
`container_name=${shellQuote(k3s.containerName)}`,
`service_name=${shellQuote(k3s.serviceName)}`,
`service_port=${shellQuote(String(k3s.servicePort))}`,
`health_path=${shellQuote(k3s.healthPath)}`,
`manifest=${shellQuote(k3s.manifestPath)}`,
"export KUBECONFIG=/etc/rancher/k3s/k3s.yaml",
"command -v docker >/dev/null",
"command -v kubectl >/dev/null",
"command -v ctr >/dev/null",
"test -S /run/k3s/containerd/containerd.sock",
`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_dockerfile=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/dockerfile\" }}')",
"test \"$label_commit\" = \"$commit\"",
"test \"$label_service\" = \"$service_id\"",
"test \"$label_dockerfile\" = \"$dockerfile\"",
"docker tag \"$registry_image\" \"$stable_image\"",
"docker tag \"$registry_image\" \"$commit_image\"",
"archive=$(mktemp /tmp/unidesk-artifact-k3s-image.XXXXXX.tar)",
"trap 'rm -f \"$archive\" \"$health_tmp\"' EXIT",
"docker save \"$commit_image\" \"$stable_image\" -o \"$archive\"",
"root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images import \"$archive\" >/dev/null",
"root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F \"$stable_image\" >/dev/null",
"root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F \"$commit_image\" >/dev/null",
"if [ -f \"$manifest\" ]; then kubectl apply -f \"$manifest\"; else echo artifact_cd_manifest_missing=$manifest; fi",
"previous_commit=$(kubectl -n \"$namespace\" get deployment \"$deployment\" -o jsonpath='{.metadata.annotations.unidesk\\.ai/deploy-commit}' 2>/dev/null || true)",
"kubectl -n \"$namespace\" set image \"deployment/$deployment\" \"$container_name=$commit_image\"",
"kubectl -n \"$namespace\" set env \"deployment/$deployment\" \"UNIDESK_DEPLOY_SERVICE_ID=$service_id\" \"UNIDESK_DEPLOY_REPO=$source_repo\" \"UNIDESK_DEPLOY_COMMIT=$commit\" \"UNIDESK_DEPLOY_REQUESTED_COMMIT=$commit\"",
`kubectl -n "$namespace" annotate "deployment/$deployment" ${labels.map(shellQuote).join(" ")} --overwrite`,
"if [ -n \"$previous_commit\" ] && [ \"$previous_commit\" != \"$commit\" ]; then kubectl -n \"$namespace\" annotate \"deployment/$deployment\" \"unidesk.ai/deploy-previous-commit=$previous_commit\" --overwrite; fi",
"kubectl -n \"$namespace\" rollout status \"deployment/$deployment\" --timeout=180s",
"deployment_commit=$(kubectl -n \"$namespace\" get deployment \"$deployment\" -o jsonpath='{.metadata.annotations.unidesk\\.ai/deploy-commit}')",
"test \"$deployment_commit\" = \"$commit\"",
"deployment_image=$(kubectl -n \"$namespace\" get deployment \"$deployment\" -o jsonpath='{.spec.template.spec.containers[?(@.name==\"'\"$container_name\"'\")].image}')",
"test \"$deployment_image\" = \"$commit_image\"",
"containerd_config_label() {",
" ref=\"$1\"",
" key=\"$2\"",
" target_digest=$(root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images info \"$ref\" | python3 -c 'import json,sys; print(((json.load(sys.stdin).get(\"Target\") or {}).get(\"Digest\")) or \"\")')",
" test -n \"$target_digest\"",
" python3 - \"$target_digest\" \"$key\" <<'PY'",
"import json",
"import subprocess",
"import sys",
"",
"digest, key = sys.argv[1:3]",
"base = ['ctr', '--address', '/run/k3s/containerd/containerd.sock', '-n', 'k8s.io', 'content', 'get']",
"",
"def content(ref):",
" return subprocess.check_output(base + [ref])",
"",
"def labels_from(ref):",
" obj = json.loads(content(ref))",
" if isinstance(obj.get('manifests'), list):",
" for item in obj['manifests']:",
" platform = item.get('platform') or {}",
" if platform.get('architecture') in ('amd64', '') or not platform:",
" value = labels_from(item.get('digest', ''))",
" if value:",
" return value",
" return ''",
" config = obj.get('config') or {}",
" config_digest = config.get('digest') or ''",
" if not config_digest:",
" return ''",
" cfg = json.loads(content(config_digest))",
" return str(((cfg.get('config') or {}).get('Labels') or {}).get(key) or '')",
"",
"print(labels_from(digest))",
"PY",
"}",
"image_label_commit=$(containerd_config_label \"$commit_image\" 'unidesk.ai/source-commit')",
"image_label_service=$(containerd_config_label \"$commit_image\" 'unidesk.ai/service-id')",
"test \"$image_label_commit\" = \"$commit\"",
"test \"$image_label_service\" = \"$service_id\"",
"pod=$(kubectl -n \"$namespace\" get pod -l \"app.kubernetes.io/name=$deployment\" -o jsonpath='{.items[0].metadata.name}')",
"test -n \"$pod\"",
"pod_image=$(kubectl -n \"$namespace\" get pod \"$pod\" -o jsonpath='{.spec.containers[?(@.name==\"'\"$container_name\"'\")].image}')",
"test \"$pod_image\" = \"$commit_image\"",
"pod_image_id=$(kubectl -n \"$namespace\" get pod \"$pod\" -o jsonpath='{.status.containerStatuses[?(@.name==\"'\"$container_name\"'\")].imageID}')",
"if [ -z \"$pod_image_id\" ]; then pod_image_id=$(kubectl -n \"$namespace\" get pod \"$pod\" -o jsonpath='{.status.containerStatuses[0].imageID}'); fi",
"case \"$health_path\" in /*) ;; *) health_path=\"/$health_path\" ;; esac",
"proxy_path=\"/api/v1/namespaces/$namespace/services/http:$service_name:$service_port/proxy$health_path\"",
"health_tmp=$(mktemp /tmp/unidesk-artifact-health.XXXXXX.json)",
"for attempt in $(seq 1 60); do",
" if kubectl get --raw \"$proxy_path\" > \"$health_tmp\" 2>/tmp/unidesk-artifact-health.err; then",
" health_commit=$(python3 -c 'import json,sys; print(((json.load(open(sys.argv[1])).get(\"deploy\") or {}).get(\"commit\") or \"\"))' \"$health_tmp\" 2>/dev/null || true)",
" health_ok=$(python3 -c 'import json,sys; print(\"true\" if json.load(open(sys.argv[1])).get(\"ok\") is True else \"false\")' \"$health_tmp\" 2>/dev/null || true)",
" echo \"artifact_cd_health_probe attempt=$attempt ok=$health_ok commit=$health_commit\"",
" if [ \"$health_ok\" = \"true\" ] && [ \"$health_commit\" = \"$commit\" ]; then break; fi",
" else",
" echo \"artifact_cd_health_probe attempt=$attempt request=failed\"",
" fi",
" if [ \"$attempt\" = \"60\" ]; then echo artifact_cd_health_failed >&2; cat \"$health_tmp\" >&2 || true; exit 1; fi",
" sleep 2",
"done",
"printf 'artifact_cd_service=%s\\nartifact_cd_source_image=%s\\nartifact_cd_stable_image=%s\\nartifact_cd_runtime_image=%s\\nartifact_cd_commit=%s\\nartifact_cd_previous_commit=%s\\nartifact_cd_pod=%s\\nartifact_cd_pod_image=%s\\nartifact_cd_pod_image_id=%s\\nartifact_cd_image_label_commit=%s\\nartifact_cd_health_commit=%s\\n' \"$service_id\" \"$registry_image\" \"$stable_image\" \"$commit_image\" \"$commit\" \"$previous_commit\" \"$pod\" \"$pod_image\" \"$pod_image_id\" \"$image_label_commit\" \"$health_commit\"",
].join("\n");
}
async function deployD601K3sArtifactNow(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec): Promise<Record<string, unknown>> {
const commit = options.commit;
if (commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
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 deployScript = d601K3sArtifactDeployScript(options, spec, commit);
const deploy = runRemoteScript(options, deployScript, Math.max(options.timeoutMs, 420_000));
if (deploy.exitCode !== 0 || deploy.timedOut) {
return {
ok: false,
supported: true,
serviceId: spec.serviceId,
step: "d601-k3s-artifact-deploy",
sourceImage,
registryProbe: commandTail(registryProbe),
deploy: commandTail(deploy),
rollback: rollbackInfo(spec, commit),
};
}
return {
ok: true,
supported: true,
serviceId: spec.serviceId,
commit,
providerId: options.providerId,
sourceRepo: options.sourceRepo,
sourceImage,
stableImage: spec.targetImage,
runtimeImage: spec.targetCommitImage(commit),
registryProbe: commandTail(registryProbe),
deploy: commandTail(deploy),
validation: {
liveCommit: commit,
imageLabelCommit: commit,
serviceHealthCommit: commit,
healthyOldVersionAccepted: false,
},
rollback: rollbackInfo(spec, commit),
};
}
async function deployServiceNow(options: ArtifactRegistryOptions): Promise<Record<string, unknown>> {
if (options.serviceId === null) throw new Error("artifact-registry deploy-service requires --service <id>");
if (options.commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
const spec = artifactConsumerSpec(options.serviceId);
if (spec === null) return unsupportedService(options.serviceId, options);
if (options.dryRun) return dryRunArtifactConsumerPlan(options, spec, options.commit);
if (spec.kind === "compose") return deployBackendCoreNow({ ...options, targetImage: options.targetImage || spec.targetImage });
return deployD601K3sArtifactNow(options, spec);
}
function deployServiceJob(args: string[], options: ArtifactRegistryOptions): Record<string, unknown> {
if (options.serviceId === null) throw new Error("artifact-registry deploy-service requires --service <id>");
if (options.commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
const spec = artifactConsumerSpec(options.serviceId);
if (spec === null) return unsupportedService(options.serviceId, options);
if (options.dryRun) return dryRunArtifactConsumerPlan(options, spec, options.commit);
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_service_cd", command, `Pull and deploy ${options.serviceId} artifact ${options.commit} from D601 registry`);
return {
ok: true,
mode: "async-job",
serviceId: options.serviceId,
job,
statusCommand: `bun scripts/cli.ts job status ${job.id}`,
tailCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 30000`,
note: "User-service CD continues in the background: D601 registry health, commit-pinned artifact check, pull/import, rollout, image-label and live health commit verification.",
rollback: rollbackInfo(spec, options.commit),
};
}
function serviceIdFromDeployBackendCoreArgs(action: ArtifactRegistryAction, options: ArtifactRegistryOptions): string | null {
return action === "deploy-backend-core" ? "backend-core" : options.serviceId;
}
function localHelp(): Record<string, unknown> {
return {
ok: true,
command: "artifact-registry plan|render|status|health|install|deploy-backend-core",
command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service",
output: "json",
usage: [
"bun scripts/cli.ts artifact-registry plan [--provider-id D601]",
@@ -768,8 +1171,15 @@ function localHelp(): Record<string, unknown> {
"bun scripts/cli.ts artifact-registry health [--provider-id D601]",
"bun scripts/cli.ts artifact-registry install [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-backend-core --commit <full-sha> [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --service decision-center --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
],
firstStage: "install now writes the rendered systemd/Compose/config files and starts the registry",
artifactConsumers: {
supportedServices: supportedArtifactConsumerServices,
unsupportedPolicy: "return structured unsupported; never fall back to legacy maintenance-channel source builds",
prodCommand: "bun scripts/cli.ts deploy apply --env prod --service decision-center",
rollbackShape: "rerun the same artifact consumer with a previous commit-pinned image",
},
defaults: defaultOptions,
};
}
@@ -777,9 +1187,10 @@ function localHelp(): Record<string, unknown> {
export async function runArtifactRegistryCommand(args: string[]): Promise<unknown> {
const action = args[0];
if (isHelpArg(action)) return localHelp();
if (action !== "plan" && action !== "render" && action !== "status" && action !== "health" && action !== "install" && action !== "deploy-backend-core") {
throw new Error("artifact-registry usage: plan|render|status|health|install|deploy-backend-core");
if (action !== "plan" && action !== "render" && action !== "status" && action !== "health" && action !== "install" && action !== "deploy-backend-core" && action !== "deploy-service") {
throw new Error("artifact-registry usage: plan|render|status|health|install|deploy-backend-core|deploy-service");
}
const typedAction = action as ArtifactRegistryAction;
const options = parseOptions(args.slice(1));
if (action === "plan") return plan(options);
if (action === "render") return { ok: true, providerId: options.providerId, render: renderBundle(options) };
@@ -792,5 +1203,9 @@ export async function runArtifactRegistryCommand(args: string[]): Promise<unknow
if (options.commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit <full-sha>");
return options.runNow ? await deployBackendCoreNow(options) : deployBackendCoreJob(args, options);
}
if (action === "deploy-service") {
const serviceId = serviceIdFromDeployBackendCoreArgs(typedAction, options);
return options.runNow || options.dryRun ? await deployServiceNow({ ...options, serviceId }) : deployServiceJob(args, { ...options, serviceId });
}
throw new Error("unreachable artifact-registry action");
}