diff --git a/docs/reference/artifact-registry.md b/docs/reference/artifact-registry.md index 2e80b479..d60796ba 100644 --- a/docs/reference/artifact-registry.md +++ b/docs/reference/artifact-registry.md @@ -64,6 +64,14 @@ bun scripts/cli.ts artifact-registry deploy-backend-core --commit `deploy-backend-core` 是 production backend-core 的 CD 入口。它必须先通过 CNCF Distribution HTTP API 确认 D601 registry 中已经存在 `unidesk/backend-core:`,随后通过 provider-gateway Host SSH 流式执行 `docker save | gzip`,在 master server 侧 `docker load`、retag、Compose `--no-build` recreate 和 live commit 验证;如果镜像不存在,应失败并要求先运行 CI artifact publication。 +`deploy-service` 是标准化后的最小通用 artifact consumer。它目前只支持 `backend-core` 和 `decision-center`,并且必须先通过 D601 registry 的 commit-pinned manifest 校验,再执行拉取、导入、部署和健康验证: + +```bash +bun scripts/cli.ts artifact-registry deploy-service --service decision-center --commit --run-now +``` + +dry-run 输出会暴露 registry probe URL、required labels、目标 image、部署形态和回滚信息。`decision-center` 的 prod 路径会在 D601 上验证 `unidesk-decision-center:` 是否存在、导入 native k3s containerd、更新 `decision-center` Deployment image/env/annotations,并通过 Kubernetes API service proxy 验证 `/health` 中的 `deploy.commit`。回滚信息通过同一 artifact consumer 的 `rollback` 字段暴露,提示操作者重新对一个旧 commit 运行相同命令,而不是切回 legacy maintenance-channel 构建。 + `status` 和 `health` 通过: ```bash @@ -106,6 +114,8 @@ docker compose -p unidesk-artifact-registry -f /home/ubuntu/.unidesk/artifact-re 6. master server retag 为 Compose 使用的 backend-core 镜像名,并执行 `docker compose up -d --no-build --no-deps --force-recreate backend-core`。 7. 部署后通过 image label、runtime env、health payload 验证 live commit。 +Decision Center follows the same artifact-consumer pattern, except the runtime target is native k3s on D601 instead of the master-server Compose stack. The consumer must check the registry manifest, pull the commit-pinned image, import it into `/run/k3s/containerd/containerd.sock`, set the Deployment image to the commit tag, stamp `UNIDESK_DEPLOY_*` env/annotations, and reject an old healthy revision if the live commit does not match. + 这个 CD 路径必须满足: - source commit 来自 pushed Git,不来自 dirty worktree。 diff --git a/docs/reference/deploy.md b/docs/reference/deploy.md index 7fe18341..25bb227b 100644 --- a/docs/reference/deploy.md +++ b/docs/reference/deploy.md @@ -106,7 +106,7 @@ Maintenance-channel direct D601 apply must not deploy dev Code Queue; the CLI re `bun scripts/cli.ts deploy plan --env dev [--service ]` reads `origin/master:deploy.json#environments.dev` and prints a dry-run environment plan without checking or mutating live runtime resources. `deploy check --env dev` uses the same dry-run environment plan. `--env prod` is available for parity as a dry-run planning path; it reads `origin/master:deploy.json#environments.prod` and must not use a dirty local `deploy.json`. -`bun scripts/cli.ts deploy apply [--file deploy.json | --env dev] [--service ] [--dry-run] [--force]` starts an asynchronous job only for supported targets. Use `bun scripts/cli.ts job status --tail-bytes 30000` to observe progress. `--dry-run` resolves the same plan but does not build or replace runtime objects. `--force` rebuilds even when the live commit matches. Environment apply is not the dev e2e trigger; use `bun scripts/cli.ts ci run-dev-e2e` for the Git-controlled temporary namespace smoke flow. `--env dev` apply is enabled only for `backend-core` and `frontend`; `--env prod` apply is rejected. +`bun scripts/cli.ts deploy apply [--file deploy.json | --env dev|prod] [--service ] [--commit ] [--dry-run] [--force]` starts an asynchronous job only for supported targets. Use `bun scripts/cli.ts job status --tail-bytes 30000` to observe progress. `--dry-run` resolves the same plan but does not build or replace runtime objects. `--force` rebuilds even when the live commit matches. Environment apply is not the dev e2e trigger; use `bun scripts/cli.ts ci run-dev-e2e` for the Git-controlled temporary namespace smoke flow. `--env dev` apply is enabled only for `backend-core` and `frontend`; `--env prod` apply now exposes the D601 registry artifact consumer for `backend-core` and `decision-center`. Unsupported prod services return a structured `unsupported` payload instead of silently falling back to a maintenance-channel source build. All deploy commands output JSON. Long operations must use `.state/jobs/` and bounded log tails; no deploy path may succeed with missing progress output. @@ -169,7 +169,7 @@ Code Queue health and diagnostics must cover its k3s dependencies, not only sche Existing service-specific commands such as Code Queue deploy are disabled as direct D601 deploy paths. Their build/import/rollout semantics should converge later into one controlled target-side deployment path instead of keeping parallel implementations. -Decision Center is a standard `k3sctl-managed` service in this model, but D601 maintenance-channel direct apply must not deploy it. Future controlled CD for Decision Center should build `src/components/microservices/decision-center/Dockerfile` on D601, import `unidesk-decision-center:d601` into native k3s containerd, apply `src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml`, stamp the Deployment, and verify health through `/api/microservices/decision-center/health`. It must not add a main-server Compose service, NodePort, hostPort, or provider-gateway direct HTTP backend for Decision Center. +Decision Center is a standard `k3sctl-managed` service in this model, but D601 maintenance-channel direct apply must not deploy it. Controlled CD for Decision Center uses the D601 registry artifact consumer: it verifies `unidesk/decision-center:` exists in the registry, imports `unidesk-decision-center:` into native k3s containerd, applies `src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml`, stamps the Deployment, and verifies health through `/api/microservices/decision-center/health`. It must not add a main-server Compose service, NodePort, hostPort, or provider-gateway direct HTTP backend for Decision Center. ## CI Separation diff --git a/scripts/src/artifact-registry.ts b/scripts/src/artifact-registry.ts index 8d1716ca..e87bb7d7 100644 --- a/scripts/src/artifact-registry.ts +++ b/scripts/src/artifact-registry.ts @@ -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 = { + "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 { }; } +function artifactConsumerSpec(serviceId: string): ArtifactConsumerSpec | null { + return (artifactConsumerSpecs as Record)[serviceId] ?? null; +} + +function unsupportedService(serviceId: string, options: ArtifactRegistryOptions): Record { + 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): 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> { - const commit = options.commit; - if (commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit "); - 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 | 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> { + const commit = options.commit; + if (commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit "); + 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 { + 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 { + 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 `, + 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> { + const commit = options.commit; + if (commit === null) throw new Error("artifact-registry deploy-service requires --commit "); + 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> { + if (options.serviceId === null) throw new Error("artifact-registry deploy-service requires --service "); + if (options.commit === null) throw new Error("artifact-registry deploy-service requires --commit "); + 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 { + if (options.serviceId === null) throw new Error("artifact-registry deploy-service requires --service "); + if (options.commit === null) throw new Error("artifact-registry deploy-service requires --commit "); + 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 { 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 { "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 [--run-now] [--provider-id D601]", + "bun scripts/cli.ts artifact-registry deploy-service --service decision-center --commit [--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 { export async function runArtifactRegistryCommand(args: string[]): Promise { 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"); 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"); } diff --git a/scripts/src/deploy.ts b/scripts/src/deploy.ts index a83946b9..650c7ccf 100644 --- a/scripts/src/deploy.ts +++ b/scripts/src/deploy.ts @@ -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(["backend-core", "frontend", "k3sctl-adapter", "code-queue"]); const devApplySupportedServiceIds = new Set(["backend-core", "frontend"]); +const prodArtifactConsumerServiceIds = new Set(["backend-core", "decision-center"]); const deployEnvironmentTargets: Record = { dev: { environment: "dev", @@ -186,7 +189,7 @@ export function deployHelp(action: string | undefined = undefined): Record", 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 ", 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 ", 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 ", description: "Limit reconcile to one service from the manifest." }, + { name: "--commit ", 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 ", 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 { + 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> { + 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; + 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 { + 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> { 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.`); diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 62632678..54054ada 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -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 ", description: "Show one daily diary Markdown entry." }, + { command: "decision diary edit|upsert --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 ", 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 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 [--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 [--title text] [--type meeting|decision]", "bun scripts/cli.ts decision list [--type ...] [--status ...] [--level ...] [--limit N]", "bun scripts/cli.ts decision show ", "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 [--run-now] [--provider-id D601]", + "bun scripts/cli.ts artifact-registry deploy-service --service decision-center --commit [--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", ], };