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
+10
View File
@@ -64,6 +64,14 @@ bun scripts/cli.ts artifact-registry deploy-backend-core --commit <full-sha>
`deploy-backend-core` 是 production backend-core 的 CD 入口。它必须先通过 CNCF Distribution HTTP API 确认 D601 registry 中已经存在 `unidesk/backend-core:<commit>`,随后通过 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 <full-sha> --run-now
```
dry-run 输出会暴露 registry probe URL、required labels、目标 image、部署形态和回滚信息。`decision-center` 的 prod 路径会在 D601 上验证 `unidesk-decision-center:<commit>` 是否存在、导入 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。
+2 -2
View File
@@ -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 <id>]` 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 <id>] [--dry-run] [--force]` starts an asynchronous job only for supported targets. Use `bun scripts/cli.ts job status <jobId> --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 <id>] [--commit <full-sha>] [--dry-run] [--force]` starts an asynchronous job only for supported targets. Use `bun scripts/cli.ts job status <jobId> --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:<commit>` exists in the registry, imports `unidesk-decision-center:<commit>` 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
+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",
],
};