diff --git a/docs/reference/artifact-registry.md b/docs/reference/artifact-registry.md index 0edabf9e..e8eb34b0 100644 --- a/docs/reference/artifact-registry.md +++ b/docs/reference/artifact-registry.md @@ -154,6 +154,18 @@ docker compose -p unidesk-artifact-registry -f /home/ubuntu/.unidesk/artifact-re Baidu Netdisk is the first main-server direct user-service sample in this flow. Its dev validation command and prod CD command both consume `127.0.0.1:5000/unidesk/baidu-netdisk:` and must not build source on the master server. Frontend is the standard UniDesk UI artifact sample: CI publishes `127.0.0.1:5000/unidesk/frontend:`, production CD consumes that artifact into the master-server Compose `frontend` service, and dev CD consumes the same artifact into D601 native k3s `frontend-dev`. Project Manager, OA Event Flow and Todo Note use the same master-server Compose artifact-consumer shape as Baidu Netdisk, with `project-manager-backend`, `oa-event-flow-backend` and `todo-note-backend` as the runtime containers; Todo Note keeps its external source repository and requires a pre-existing `127.0.0.1:5000/unidesk/todo-note:` artifact. Its consumer injects `UNIDESK_TODO_NOTE_DEPLOY_*` runtime metadata and the container health probe combines `/api/health` with that metadata before enforcing `deploy.commit` / `deploy.requestedCommit`. Code Queue Manager is supported as an artifact consumer for validation, but prod live apply is supervisor-gated. Neither path may use `server rebuild frontend`, dirty source, mutable `latest`, or target-side frontend source builds as release truth. Decision Center, MDTODO and ClaudeQQ follow the same artifact-consumer pattern in both dev and prod, except the runtime target is native k3s on D601 instead of the master-server Compose stack. Dev `code-queue` is a deliberately narrower consumer: it validates only the `unidesk-dev` Code Queue execution slice and has no prod target. The k3s 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, verify health through the Kubernetes API service proxy, and reject an old healthy revision if the live commit or requested commit does not match. +Main-server direct production dry-runs for the user-service matrix must be enough to review the blast radius before any live apply. `artifact-registry deploy-service --env prod --service --commit --dry-run` is non-mutating and reports this fixed target shape: + +| service id | CI producer | registry artifact | dry-run Compose target | planned recreate | commit verification | +| --- | --- | --- | --- | --- | --- | +| `project-manager` | `ci publish-user-service`, UniDesk repo `src/components/microservices/project-manager/Dockerfile` | `127.0.0.1:5000/unidesk/project-manager:` | `project-manager` / `project-manager-backend` | `docker compose up -d --no-build --no-deps --force-recreate project-manager` | image labels plus `/health.deploy.commit` and `deploy.requestedCommit` | +| `oa-event-flow` | `ci publish-user-service`, UniDesk repo `src/components/microservices/oa-event-flow/Dockerfile` | `127.0.0.1:5000/unidesk/oa-event-flow:` | `oa-event-flow` / `oa-event-flow-backend` | `docker compose up -d --no-build --no-deps --force-recreate oa-event-flow` | image labels plus `/health.deploy.commit` and `deploy.requestedCommit` | +| `todo-note` | `ci publish-user-service`, external `https://gitee.com/Lyon1998/todo_note` `Dockerfile` | `127.0.0.1:5000/unidesk/todo-note:` | `todo-note` / `todo-note-backend` | `docker compose up -d --no-build --no-deps --force-recreate todo-note` | image labels plus synthetic health deploy metadata from `/api/health` and `UNIDESK_TODO_NOTE_DEPLOY_*` | +| `baidu-netdisk` | `ci publish-user-service`, UniDesk repo `src/components/microservices/baidu-netdisk/Dockerfile` | `127.0.0.1:5000/unidesk/baidu-netdisk:` | `baidu-netdisk` / `baidu-netdisk-backend` | `docker compose up -d --no-build --no-deps --force-recreate baidu-netdisk` | image labels, `/health.deploy.commit`, `deploy.requestedCommit`, secret presence preflight and auth health gate on live apply | +| `frontend` | `ci publish-user-service`, UniDesk repo `src/components/frontend/Dockerfile` | `127.0.0.1:5000/unidesk/frontend:` | `frontend` / `unidesk-frontend` | `docker compose up -d --no-build --no-deps --force-recreate frontend` | image labels plus `/health.deploy.commit` and `deploy.requestedCommit` | + +The dry-run matrix intentionally excludes production backend-core and Code Queue execution-plane mutation. `code-queue-mgr` may be used as a read-only reference for the same dry-run output style, but its prod live apply remains supervisor-gated and its plan must state that scheduler, runner, queued task, interrupt and cancellation state are outside the target set. + 这个 CD 路径必须满足: - source commit 来自 pushed Git,不来自 dirty worktree。 diff --git a/scripts/artifact-consumer-dry-run-matrix-test.ts b/scripts/artifact-consumer-dry-run-matrix-test.ts new file mode 100644 index 00000000..9538f5d9 --- /dev/null +++ b/scripts/artifact-consumer-dry-run-matrix-test.ts @@ -0,0 +1,170 @@ +import { readFileSync } from "node:fs"; +import { rootPath } from "./src/config"; +import { runArtifactRegistryCommand } from "./src/artifact-registry"; + +type JsonRecord = Record; + +interface ServiceCase { + serviceId: string; + sourceRepo: string; + dockerfile: string; + registryRepository: string; + composeService: string; + containerName: string; + targetImage: string; + deployEnvPrefix: string; +} + +function asRecord(value: unknown, label: string): JsonRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object: ${JSON.stringify(value)}`); + } + return value as JsonRecord; +} + +function asArray(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array: ${JSON.stringify(value)}`); + return value; +} + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +const commit = "0123456789abcdef0123456789abcdef01234567"; + +const serviceCases: ServiceCase[] = [ + { + serviceId: "project-manager", + sourceRepo: "https://github.com/pikasTech/unidesk", + dockerfile: "src/components/microservices/project-manager/Dockerfile", + registryRepository: "unidesk/project-manager", + composeService: "project-manager", + containerName: "project-manager-backend", + targetImage: "project-manager", + deployEnvPrefix: "UNIDESK_PROJECT_MANAGER_DEPLOY", + }, + { + serviceId: "oa-event-flow", + sourceRepo: "https://github.com/pikasTech/unidesk", + dockerfile: "src/components/microservices/oa-event-flow/Dockerfile", + registryRepository: "unidesk/oa-event-flow", + composeService: "oa-event-flow", + containerName: "oa-event-flow-backend", + targetImage: "oa-event-flow", + deployEnvPrefix: "UNIDESK_OA_EVENT_FLOW_DEPLOY", + }, + { + serviceId: "todo-note", + sourceRepo: "https://gitee.com/Lyon1998/todo_note", + dockerfile: "Dockerfile", + registryRepository: "unidesk/todo-note", + composeService: "todo-note", + containerName: "todo-note-backend", + targetImage: "todo-note", + deployEnvPrefix: "UNIDESK_TODO_NOTE_DEPLOY", + }, + { + serviceId: "baidu-netdisk", + sourceRepo: "https://github.com/pikasTech/unidesk", + dockerfile: "src/components/microservices/baidu-netdisk/Dockerfile", + registryRepository: "unidesk/baidu-netdisk", + composeService: "baidu-netdisk", + containerName: "baidu-netdisk-backend", + targetImage: "baidu-netdisk", + deployEnvPrefix: "UNIDESK_BAIDU_NETDISK_DEPLOY", + }, + { + serviceId: "frontend", + sourceRepo: "https://github.com/pikasTech/unidesk", + dockerfile: "src/components/frontend/Dockerfile", + registryRepository: "unidesk/frontend", + composeService: "frontend", + containerName: "unidesk-frontend", + targetImage: "unidesk-frontend", + deployEnvPrefix: "UNIDESK_FRONTEND_DEPLOY", + }, +]; + +const ciCatalog = asRecord(JSON.parse(readFileSync(rootPath("CI.json"), "utf8")) as unknown, "CI.json"); +const artifacts = asArray(ciCatalog.artifacts, "CI.json.artifacts").map((item, index) => asRecord(item, `CI.json.artifacts[${index}]`)); +const artifactByService = new Map(artifacts.map((item) => [String(item.serviceId), item])); + +for (const item of serviceCases) { + const artifact = asRecord(artifactByService.get(item.serviceId), `CI.json artifact ${item.serviceId}`); + const source = asRecord(artifact.source, `CI.json artifact ${item.serviceId}.source`); + const image = asRecord(artifact.image, `CI.json artifact ${item.serviceId}.image`); + assertCondition(artifact.kind === "source-build", `${item.serviceId} producer must be source-build`, artifact); + assertCondition(artifact.status === "supported", `${item.serviceId} producer must be supported`, artifact); + assertCondition(artifact.producer === "ci publish-user-service", `${item.serviceId} producer command must be publish-user-service`, artifact); + assertCondition(source.repo === item.sourceRepo, `${item.serviceId} producer source repo mismatch`, source); + assertCondition(source.dockerfile === item.dockerfile, `${item.serviceId} producer dockerfile mismatch`, source); + assertCondition(image.repository === item.registryRepository, `${item.serviceId} registry repository mismatch`, image); +} + +const plans: JsonRecord[] = []; + +for (const item of serviceCases) { + const plan = asRecord(await runArtifactRegistryCommand([ + "deploy-service", + "--env", + "prod", + "--service", + item.serviceId, + "--commit", + commit, + "--dry-run", + ]), `${item.serviceId} dry-run plan`); + const target = asRecord(plan.target, `${item.serviceId} dry-run target`); + const labels = asRecord(plan.requiredLabels, `${item.serviceId} dry-run labels`); + const registryProbe = asRecord(plan.registryProbe, `${item.serviceId} dry-run registry probe`); + const validation = asArray(plan.validation, `${item.serviceId} dry-run validation`).map(String); + + assertCondition(plan.ok === true, `${item.serviceId} dry-run must be ok`, plan); + assertCondition(plan.dryRun === true && plan.mutation === false, `${item.serviceId} dry-run must be non-mutating`, plan); + assertCondition(plan.environment === "prod", `${item.serviceId} dry-run must target prod`, plan); + assertCondition(plan.serviceId === item.serviceId, `${item.serviceId} dry-run service id mismatch`, plan); + assertCondition(plan.sourceImage === `127.0.0.1:5000/${item.registryRepository}:${commit}`, `${item.serviceId} source image mismatch`, plan); + assertCondition(labels["unidesk.ai/service-id"] === item.serviceId, `${item.serviceId} required service label missing`, labels); + assertCondition(labels["unidesk.ai/source-commit"] === commit, `${item.serviceId} required commit label missing`, labels); + assertCondition(labels["unidesk.ai/dockerfile"] === item.dockerfile, `${item.serviceId} required dockerfile label missing`, labels); + assertCondition(registryProbe.method === "HEAD", `${item.serviceId} dry-run must only plan registry HEAD probe`, registryProbe); + + assertCondition(target.kind === "compose", `${item.serviceId} target kind must be main-server Compose`, target); + assertCondition(target.runtimeHost === "main-server", `${item.serviceId} runtime host must be main-server`, target); + assertCondition(target.composeService === item.composeService, `${item.serviceId} compose service mismatch`, target); + assertCondition(target.containerName === item.containerName, `${item.serviceId} container mismatch`, target); + assertCondition(target.targetImage === item.targetImage, `${item.serviceId} stable target image mismatch`, target); + assertCondition(target.runtimeImage === `${item.targetImage}:${commit}`, `${item.serviceId} runtime image mismatch`, target); + assertCondition(target.deployEnvPrefix === item.deployEnvPrefix, `${item.serviceId} deploy env prefix mismatch`, target); + assertCondition( + target.deployCommandShape === `docker compose up -d --no-build --no-deps --force-recreate ${item.composeService}`, + `${item.serviceId} dry-run must narrow Compose recreate command`, + target, + ); + + assertCondition(validation.some((line) => line.includes("registry /v2 manifest")), `${item.serviceId} must plan registry manifest validation`, validation); + assertCondition(validation.some((line) => line.includes("image labels match service id, source commit, and Dockerfile")), `${item.serviceId} must plan image label validation`, validation); + assertCondition(validation.some((line) => line.includes("health probe succeeds") && line.includes("deploy.commit/deploy.requestedCommit")), `${item.serviceId} must plan health commit validation`, validation); + assertCondition(!JSON.stringify(plan).includes("docker compose build"), `${item.serviceId} dry-run must not include compose build`, plan); + assertCondition(!JSON.stringify(plan).includes("server rebuild"), `${item.serviceId} dry-run must not include server rebuild`, plan); + + plans.push({ + serviceId: item.serviceId, + sourceImage: plan.sourceImage, + composeService: target.composeService, + containerName: target.containerName, + deployCommandShape: target.deployCommandShape, + }); +} + +process.stdout.write(`${JSON.stringify({ + ok: true, + checks: [ + "CI.json producer catalog covers the main-server direct artifact services", + "prod dry-run plans are non-mutating and commit-pinned", + "dry-run targets only the matching main-server Compose service with --no-build --no-deps --force-recreate", + "dry-run plans image-label and health deploy commit validation", + ], + plans, +}, null, 2)}\n`); diff --git a/scripts/src/check.ts b/scripts/src/check.ts index c9b78c11..e82e6a65 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -290,6 +290,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("scripts/code-queue-pr-preflight-example.ts"), fileItem("scripts/schedule-cli-contract-test.ts"), fileItem("scripts/src/artifact-registry.ts"), + fileItem("scripts/artifact-consumer-dry-run-matrix-test.ts"), fileItem("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml"), ); } else { @@ -307,6 +308,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(commandItem("code-queue:control-plane-split-brain-diagnostics", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:control-plane-split-brain-diagnostics"], 30_000)); items.push(commandItem("code-queue:oa-publisher-degraded-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:oa-publisher-degraded-visible"], 30_000)); items.push(commandItem("baidu-netdisk:artifact-guard-contract", ["bun", "scripts/baidu-netdisk-artifact-guard-contract-test.ts"], 30_000)); + items.push(commandItem("artifact-registry:direct-compose-dry-run-matrix", ["bun", "scripts/artifact-consumer-dry-run-matrix-test.ts"], 30_000)); items.push(commandItem("schedule:cli-contract", ["bun", "scripts/schedule-cli-contract-test.ts"], 30_000)); items.push(commandItem("gh:issue-guard-contract", ["bun", "scripts/gh-cli-issue-guard-contract-test.ts"], 30_000)); items.push(commandItem("gh:pr-contract", ["bun", "scripts/gh-cli-pr-contract-test.ts"], 30_000)); @@ -318,6 +320,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(skippedItem("code-queue:pr-preflight-contract", "Code Queue PR preflight contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:liveness-diagnostics-fixtures", "Code Queue liveness diagnostics fixtures are opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("baidu-netdisk:artifact-guard-contract", "Baidu Netdisk artifact guard contract is opt-in with script checks", "--scripts-typecheck or --full")); + items.push(skippedItem("artifact-registry:direct-compose-dry-run-matrix", "main-server direct artifact consumer dry-run matrix is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("schedule:cli-contract", "Schedule CLI contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("gh:issue-guard-contract", "GitHub issue CLI contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("gh:pr-contract", "GitHub PR CLI contract is opt-in with script checks", "--scripts-typecheck or --full"));