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");
}
+118 -7
View File
@@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url";
import { runCommand } from "./command";
import { type UniDeskConfig, type UniDeskMicroserviceConfig, repoRoot, rootPath } from "./config";
import { ensureGithubSshIdentityForProvider } from "./deploy-ssh-identity";
import { runArtifactRegistryCommand } from "./artifact-registry";
import { startJob } from "./jobs";
import { coreInternalFetch } from "./microservices";
@@ -27,6 +28,7 @@ interface DeployOptions {
file: string;
environment: DeployEnvironment | null;
serviceId: string | null;
commitOverride: string | null;
runNow: boolean;
dryRun: boolean;
force: boolean;
@@ -133,6 +135,7 @@ const nativeK3sCtrAddress = "/run/k3s/containerd/containerd.sock";
const unideskRepoUrl = "https://github.com/pikasTech/unidesk";
const d601MaintenanceDeployAllowedServiceIds = new Set<string>(["backend-core", "frontend", "k3sctl-adapter", "code-queue"]);
const devApplySupportedServiceIds = new Set<string>(["backend-core", "frontend"]);
const prodArtifactConsumerServiceIds = new Set<string>(["backend-core", "decision-center"]);
const deployEnvironmentTargets: Record<DeployEnvironment, DeployEnvironmentTarget> = {
dev: {
environment: "dev",
@@ -186,7 +189,7 @@ export function deployHelp(action: string | undefined = undefined): Record<strin
usage: {
check: "bun scripts/cli.ts deploy check [--file deploy.json | --env dev|prod] [--service id]",
plan: "bun scripts/cli.ts deploy plan [--file deploy.json | --env dev|prod] [--service id]",
apply: "bun scripts/cli.ts deploy apply [--file deploy.json | --env dev] [--service id] [--dry-run] [--force] [--timeout-ms N] [--run-now]",
apply: "bun scripts/cli.ts deploy apply [--file deploy.json | --env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force] [--timeout-ms N] [--run-now]",
},
actions: {
check: "Validate desired repo+commit state against live service health and commit markers.",
@@ -195,8 +198,9 @@ export function deployHelp(action: string | undefined = undefined): Record<strin
},
options: [
{ name: "--file <path>", default: defaultDeployFile, description: "Desired-state manifest path relative to the repo root. JSON and ESM JS manifests are supported, for example deploy.json or develop.js. Local manifest apply allows k3sctl-adapter and explicit production code-queue controlled rollout on D601." },
{ name: "--env <dev|prod>", description: "Read the named environment from origin/master:deploy.json. Dev apply is enabled only for backend-core and frontend in D601 unidesk-dev; prod apply is disabled." },
{ name: "--env <dev|prod>", description: "Read the named environment from origin/master:deploy.json. Dev apply is enabled only for backend-core and frontend in D601 unidesk-dev; prod apply uses the D601 registry artifact consumer for backend-core and decision-center." },
{ name: "--service <id>", description: "Limit reconcile to one service from the manifest." },
{ name: "--commit <full-sha>", description: "Prod artifact rollback/apply override for a selected service; the image must already exist in D601 registry." },
{ name: "--dry-run", description: "Prepare and validate without mutating the target service." },
{ name: "--force", description: "Redeploy even when the live commit appears up to date." },
{ name: "--timeout-ms <n>", default: defaultTimeoutMs, description: "Per-step timeout budget where supported." },
@@ -464,10 +468,16 @@ function environmentOption(args: string[]): DeployEnvironment | null {
function parseOptions(args: string[]): DeployOptions {
const environment = environmentOption(args);
if (environment !== null && args.includes("--file")) throw new Error("deploy --env reads deploy.json from the fixed Git ref and cannot be combined with --file");
const commitOverride = optionValue(args, ["--commit"]);
const serviceId = optionValue(args, ["--service", "--service-id"]) ?? null;
if (commitOverride !== undefined && environment !== "prod") throw new Error("deploy --commit is only supported for --env prod artifact rollback/apply");
if (commitOverride !== undefined && !isFullGitSha(commitOverride)) throw new Error("deploy --commit must be a full 40-character commit SHA");
if (commitOverride !== undefined && serviceId === null) throw new Error("deploy --commit requires --service so prod rollback/apply is unambiguous");
return {
file: optionValue(args, ["--file"]) ?? defaultDeployFile,
environment,
serviceId: optionValue(args, ["--service", "--service-id"]) ?? null,
serviceId,
commitOverride: commitOverride?.toLowerCase() ?? null,
runNow: args.includes("--run-now"),
dryRun: args.includes("--dry-run"),
force: args.includes("--force"),
@@ -2416,15 +2426,19 @@ function environmentDryRunPlan(
const environment = options.environment;
if (environment === null) throw new Error("environment dry-run requires --env");
const target = deployEnvironmentTargets[environment];
const services = options.serviceId === null
const selectedServices = options.serviceId === null
? manifest.services
: manifest.services.filter((service) => service.id === options.serviceId);
if (options.serviceId !== null && services.length === 0) {
if (options.serviceId !== null && selectedServices.length === 0) {
throw new Error(`deploy manifest ${source.ref}:deploy.json does not contain service: ${options.serviceId}`);
}
const services = selectedServices.map((service) => ({
...service,
commitId: options.commitOverride !== null && service.id === options.serviceId ? options.commitOverride : service.commitId,
}));
const fingerprint = databaseFingerprint(target);
return {
ok: true,
ok: environment === "prod" ? services.every((service) => prodArtifactConsumerServiceIds.has(service.id)) : true,
action,
mode: "environment-ref-dry-run",
dryRun: true,
@@ -2458,6 +2472,18 @@ function environmentDryRunPlan(
targetNamespace: target.namespace,
providerId: target.provider.providerId,
databaseFingerprint: fingerprint,
deploymentPath: environment === "prod"
? prodArtifactConsumerServiceIds.has(service.id)
? "d601-registry-artifact-consumer"
: "unsupported"
: "d601-dev-target-side-build",
unsupported: environment === "prod" && !prodArtifactConsumerServiceIds.has(service.id)
? {
ok: false,
supported: false,
reason: "No standardized prod D601 registry artifact consumer is implemented for this service; legacy maintenance-channel deployment is not allowed.",
}
: undefined,
})),
};
}
@@ -2479,6 +2505,86 @@ function d601MaintenanceDeployBlockMessage(blocked: string[]): string {
return `D601 target-side deployment is enabled only for k3sctl-adapter and dev backend-core/frontend; blocked services: ${blocked.join(", ")}. Use ci run-dev-e2e for dev smoke verification.`;
}
function selectedEnvironmentServices(manifest: DeployManifest, serviceId: string | null): DeployManifestService[] {
const services = serviceId === null ? manifest.services : manifest.services.filter((service) => service.id === serviceId);
if (serviceId !== null && services.length === 0) throw new Error(`deploy manifest does not contain service: ${serviceId}`);
return services;
}
function prodArtifactUnsupportedServices(manifest: DeployManifest, serviceId: string | null): DeployManifestService[] {
return selectedEnvironmentServices(manifest, serviceId).filter((service) => !prodArtifactConsumerServiceIds.has(service.id));
}
function prodArtifactUnsupportedResult(services: DeployManifestService[]): Record<string, unknown> {
return {
ok: false,
supported: false,
error: "unsupported",
services: services.map((service) => ({
id: service.id,
repo: service.repo,
commitId: service.commitId,
supported: false,
reason: "No standardized prod D601 registry artifact consumer is implemented for this service.",
})),
policy: "prod deploy must not silently fall back to legacy D601 maintenance-channel source builds",
supportedServices: Array.from(prodArtifactConsumerServiceIds),
};
}
async function runProdArtifactApplyNow(manifest: DeployManifest, options: DeployOptions): Promise<Record<string, unknown>> {
const selected = selectedEnvironmentServices(manifest, options.serviceId);
const unsupported = selected.filter((service) => !prodArtifactConsumerServiceIds.has(service.id));
if (unsupported.length > 0) return prodArtifactUnsupportedResult(unsupported);
const startedAt = nowIso();
const results = [];
for (const service of selected) {
const commit = options.commitOverride ?? service.commitId;
const artifactArgs = [
"deploy-service",
"--service", service.id,
"--commit", commit,
"--source-repo", service.repo,
"--timeout-ms", String(options.timeoutMs),
"--run-now",
...(options.dryRun ? ["--dry-run"] : []),
];
progressLine("prod-artifact-cd", options.dryRun ? "dry-run" : "reconcile", { serviceId: service.id, commit });
results.push(await runArtifactRegistryCommand(artifactArgs));
const latest = results.at(-1) as Record<string, unknown>;
if (latest.ok !== true) break;
}
return {
ok: results.every((result) => asRecord(result)?.ok === true),
action: "apply",
environment: "prod",
executor: "d601-registry-artifact-consumer",
dryRun: options.dryRun,
startedAt,
finishedAt: nowIso(),
results,
};
}
function prodArtifactApplyJob(args: string[], options: DeployOptions): Record<string, unknown> {
if (!options.runNow && options.dryRun) {
throw new Error("deploy apply --env prod --dry-run should run in the foreground so the plan is visible immediately");
}
const runArgs = args.includes("--run-now") ? args : [...args, "--run-now"];
const command = [process.execPath, rootPath("scripts", "cli.ts"), "deploy", ...runArgs];
const source = `${deployEnvironmentTargets.prod.gitRef}:deploy.json#environments.prod`;
const job = startJob("deploy_prod_artifact_apply", command, `Deploy prod artifact consumer from ${source}${options.serviceId === null ? "" : ` service=${options.serviceId}`}`);
return {
ok: true,
mode: "async-job",
executor: "d601-registry-artifact-consumer",
job,
statusCommand: `bun scripts/cli.ts job status ${job.id}`,
tailCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 30000`,
note: "Production CD continues in the background: D601 registry commit-pinned image check, pull/import, rollout, image-label and live health commit verification.",
};
}
async function runApplyNow(config: UniDeskConfig, manifest: DeployManifest, options: DeployOptions): Promise<Record<string, unknown>> {
const selected = selectServices(config, manifest, options.serviceId);
const startedAt = nowIso();
@@ -2525,7 +2631,12 @@ export async function runDeployCommand(config: UniDeskConfig | null, args: strin
if (options.environment !== null) {
const { manifest, source } = readEnvironmentDeployManifest(options.environment);
if (action === "check" || action === "plan") return environmentDryRunPlan(manifest, source, options, action);
if (options.environment !== "dev") throw new Error("deploy apply --env prod is not enabled yet");
if (options.environment === "prod") {
const unsupported = prodArtifactUnsupportedServices(manifest, options.serviceId);
if (unsupported.length > 0) return prodArtifactUnsupportedResult(unsupported);
if (options.dryRun || options.runNow) return await runProdArtifactApplyNow(manifest, options);
return prodArtifactApplyJob(args, options);
}
const unsupported = unsupportedDevApplyServices(manifest, options.serviceId);
if (unsupported.length > 0) {
throw new Error(`deploy apply --env dev currently supports only backend-core and frontend; unsupported selected services: ${unsupported.join(", ")}. Use ci run-dev-e2e for smoke verification.`);
+10 -5
View File
@@ -32,11 +32,13 @@ export function rootHelp(): unknown {
{ command: "decision diary list [--month YYYY-MM] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--limit N] [--include-body]", description: "List daily Markdown diary entries stored by Decision Center." },
{ command: "decision diary months", description: "List available Decision Center diary months with day counts." },
{ command: "decision diary show <YYYY-MM-DD|id>", description: "Show one daily diary Markdown entry." },
{ command: "decision diary edit|upsert <YYYY-MM-DD|id> --body-file path [--title text] [--source-file path] [--tag tag]", description: "Create or edit one daily diary entry through PUT /api/diary/entries/:idOrDate via backend-core proxy." },
{ command: "decision list [--type ...] [--status ...] [--level ...] [--linked-goal-id id] [--limit N]", description: "List Decision Center records through the user-service proxy." },
{ command: "decision requirement list|upsert [--id id] [--title text] [--body-file path] [--type goal|decision|blocker|debt|experiment]", description: "Manage requirement records over the existing records model, excluding meeting records." },
{ command: "decision show <id>", description: "Show one Decision Center record." },
{ command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest; --env reads origin/master:deploy.json environments and can apply supported dev services." },
{ command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest; --env reads origin/master:deploy.json environments and can apply supported dev services or supported prod artifact consumers." },
{ command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." },
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only production backend-core artifact CD." },
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services." },
{ command: "schedule list|get|runs|run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N." },
{ command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },
{ command: "codex deploy <commitId> [--provider-id D601] [--timeout-ms N]", description: "Compatibility wrapper for deploy apply --service code-queue with a temporary repo+commit manifest." },
@@ -136,14 +138,15 @@ function microserviceHelp(): unknown {
function decisionHelp(): unknown {
return {
command: "decision upload|list|show|health|diary",
command: "decision upload|list|show|health|diary|requirement",
output: "json",
usage: [
"bun scripts/cli.ts decision upload <markdown-file> [--title text] [--type meeting|decision]",
"bun scripts/cli.ts decision list [--type ...] [--status ...] [--level ...] [--limit N]",
"bun scripts/cli.ts decision show <id>",
"bun scripts/cli.ts decision health",
"bun scripts/cli.ts decision diary import|list|months|show ...",
"bun scripts/cli.ts decision diary import|list|months|show|edit|upsert ...",
"bun scripts/cli.ts decision requirement list|upsert ...",
],
description: "Operate Decision Center through the registered user-service proxy.",
};
@@ -247,7 +250,7 @@ function devEnvHelp(): unknown {
function artifactRegistryHelp(): unknown {
return {
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]",
@@ -256,6 +259,7 @@ function artifactRegistryHelp(): 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]",
],
description: "Manage the declaration, rendered files and readonly checks for the D601 host-managed CNCF Distribution artifact registry.",
boundary: [
@@ -263,6 +267,7 @@ function artifactRegistryHelp(): unknown {
"service is host-managed by systemd + Docker Compose, not k3s-managed",
"install writes the rendered host unit/config and starts the registry",
"deploy-backend-core only pulls commit-pinned backend-core artifacts and does not build backend-core on the master server",
"deploy-service currently supports backend-core and decision-center as standardized consumers",
"status and health use provider-gateway Host SSH readonly checks",
],
};