standardize user-service artifact CD
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user