From f701eab29d01bfb6242faa5acfa55e4ba930e07b Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 10:09:22 +0000 Subject: [PATCH] fix: expose baidu netdisk secret source contract --- docs/reference/artifact-registry.md | 4 +- docs/reference/cicd-standardization.md | 8 +- docs/reference/deploy.md | 6 +- .../artifact-consumer-dry-run-matrix-test.ts | 13 ++ ...du-netdisk-artifact-guard-contract-test.ts | 43 +++++ .../deploy-artifact-matrix-contract-test.ts | 14 ++ scripts/src/artifact-registry.ts | 162 +++++++++++++++--- scripts/src/deploy.ts | 25 ++- 8 files changed, 245 insertions(+), 30 deletions(-) diff --git a/docs/reference/artifact-registry.md b/docs/reference/artifact-registry.md index 8e1fd4c2..f7461417 100644 --- a/docs/reference/artifact-registry.md +++ b/docs/reference/artifact-registry.md @@ -108,7 +108,7 @@ bun scripts/cli.ts artifact-registry deploy-service --env dev --service code-que `code-queue-mgr` 是主 server Compose sidecar,不是 D601 Code Queue scheduler/runner。`artifact-registry deploy-service --env prod --service code-queue-mgr --commit --dry-run` 必须显示 target 仅为 `composeService=code-queue-mgr` / `containerName=code-queue-mgr-backend`,并在 `excludedTargets` 中说明不会触碰 `code-queue` scheduler、runner、任务、interrupt 或 cancel 状态。真实 prod apply 仍受 supervisor-only gate 保护;未经单服务授权不得执行非 dry-run apply。 -dry-run 输出会暴露 registry probe URL、required labels、目标 image、部署形态、目标 Deployment 列表、回滚信息,以及结构化 `registry`、`source` 和 `build` 字段;`registry.digest` 在 dry-run 中为 `null`,`digestSource` 指明真实 digest 来自 live registry manifest HEAD,`build.willCompile`、`build.willRunCargoBuild`、`build.willRunDockerBuild` 和 `build.willRunDockerComposeBuild` 必须为 false。dry-run 不得读取或打印运行时密钥。`backend-core --env dev` 使用 `ci publish-backend-core` 产出的 `127.0.0.1:5000/unidesk/backend-core:`,导入 D601 native k3s containerd,更新 `unidesk-dev/backend-core-dev` Deployment,设置 image/env/annotations,并通过 Kubernetes API service proxy 验证 `/health.deploy.commit` 和 `deploy.requestedCommit`;CD 阶段不得运行 Rust 编译或 Docker build。`baidu-netdisk` 是 PGDATA 备份链路依赖服务;它的 Compose artifact 路径会通过 provider-gateway Host SSH 把 `unidesk/baidu-netdisk:` 流式拉到 master server,retag 为 `baidu-netdisk` 和 `baidu-netdisk:`,在 canonical UniDesk 根目录使用 `providerGateway.upgrade.composeEnvFile` 指向的受控 env 文件写入 `UNIDESK_BAIDU_NETDISK_DEPLOY_*`,只 recreate `baidu-netdisk` service,并验证容器 image label 与 `/health.deploy.commit`。live apply 在 recreate 前必须确认受控 env 文件中存在 `UNIDESK_BAIDU_NETDISK_CLIENT_ID`、`UNIDESK_BAIDU_NETDISK_CLIENT_SECRET` 和 `UNIDESK_BAIDU_NETDISK_TOKEN_KEY`,输出只能包含 present/length/boolean;recreate 后必须验收 `/health.auth.configured`、`clientIdConfigured`、`clientSecretConfigured`、`tokenKeyConfigured` 和 `loggedIn` 全部为 true,否则返回失败或 degraded,并提示先恢复 env、单服务 recreate、再验证 `microservice health baidu-netdisk`。`findjob`、`pipeline` 和 `met-nonlinear` 的 D601 direct Compose 路径在 D601 本机验证 registry manifest、pull image、retag stable image、写入 `UNIDESK_*_DEPLOY_*` labels/env,并用 `docker compose up -d --no-build --no-deps --force-recreate ` 重新拉起对应 compose service;其中 `met-nonlinear` 当前因为 registered Dockerfile 和 long-running service contract 不一致而 live deploy blocked。`k3sctl-adapter` 是基础设施控制桥,只做 plan/dry-run,真实生产部署需要 supervisor 单独确认。`frontend --env prod` 使用同一 Compose artifact consumer,流式拉取 `unidesk/frontend:`,retag 为 `unidesk-frontend` 和 `unidesk-frontend:`,写入 `UNIDESK_FRONTEND_DEPLOY_*`,只 recreate `frontend` service,并验证 image label 与 `/health.deploy.commit`。`frontend --env dev`、`decision-center`、`mdtodo`、`claudeqq` 和 dev `code-queue` 的 k3s 路径会在 D601 上验证 commit image、导入 native k3s containerd、更新 Deployment image/env/annotations,并通过 Kubernetes API service proxy 验证 `/health` 中的 live commit 与 requested commit;dev frontend 还会在 rollout 前把主 server `config.json.auth` 同步到 `unidesk-dev` Secret/ConfigMap。`decision-center --env dev` 落到 `unidesk-dev/decision-center-dev`,prod 落到 `unidesk/decision-center`;`mdtodo` 和 `claudeqq` 使用同样的 dev 后 prod k3s consumer 结构。`code-queue --env dev` 只更新 `unidesk-dev` 中的 scheduler/read/write/provider-egress-proxy dev Deployments,prod 没有 consumer target。D601 direct Compose consumer 与 k3s-managed consumer 的区别是:前者只接触 D601 Docker/Compose 项目和私有 backend health,不创建 Kubernetes 对象;后者只通过 native k3s Deployment/Service、containerd import 和 Kubernetes API service proxy 验证 live commit。回滚信息通过同一 artifact consumer 的 `rollback` 字段暴露,提示操作者重新对一个旧 commit 运行相同命令,而不是切回 legacy maintenance-channel 构建。 +dry-run 输出会暴露 registry probe URL、required labels、目标 image、部署形态、目标 Deployment 列表、回滚信息,以及结构化 `registry`、`source` 和 `build` 字段;`registry.digest` 在 dry-run 中为 `null`,`digestSource` 指明真实 digest 来自 live registry manifest HEAD,`build.willCompile`、`build.willRunCargoBuild`、`build.willRunDockerBuild` 和 `build.willRunDockerComposeBuild` 必须为 false。dry-run 不得打印运行时密钥。`backend-core --env dev` 使用 `ci publish-backend-core` 产出的 `127.0.0.1:5000/unidesk/backend-core:`,导入 D601 native k3s containerd,更新 `unidesk-dev/backend-core-dev` Deployment,设置 image/env/annotations,并通过 Kubernetes API service proxy 验证 `/health.deploy.commit` 和 `deploy.requestedCommit`;CD 阶段不得运行 Rust 编译或 Docker build。`baidu-netdisk` 是 PGDATA 备份链路依赖服务;它的 Compose artifact 路径会通过 provider-gateway Host SSH 把 `unidesk/baidu-netdisk:` 流式拉到 master server,retag 为 `baidu-netdisk` 和 `baidu-netdisk:`,在 canonical UniDesk 根目录使用 `providerGateway.upgrade.composeEnvFile` 指向的受控 env 文件写入 `UNIDESK_BAIDU_NETDISK_DEPLOY_*`,只 recreate `baidu-netdisk` service,并验证容器 image label 与 `/health.deploy.commit`。live apply 在 recreate 前必须确认受控 env 文件中存在 `UNIDESK_BAIDU_NETDISK_CLIENT_ID`、`UNIDESK_BAIDU_NETDISK_CLIENT_SECRET` 和 `UNIDESK_BAIDU_NETDISK_TOKEN_KEY`,输出只能包含 present/length/boolean;dry-run 输出 `runtimeSecrets.secretSource`、`requiredSecretsPresent`、`missingSecretKeys` 和 `recommendedAction`,让 dev secret source blocker 可诊断但不打印值;recreate 后必须验收 `/health.auth.configured`、`clientIdConfigured`、`clientSecretConfigured`、`tokenKeyConfigured` 和 `loggedIn` 全部为 true,否则返回失败或 degraded,并提示先恢复 env、单服务 recreate、再验证 `microservice health baidu-netdisk`。`findjob`、`pipeline` 和 `met-nonlinear` 的 D601 direct Compose 路径在 D601 本机验证 registry manifest、pull image、retag stable image、写入 `UNIDESK_*_DEPLOY_*` labels/env,并用 `docker compose up -d --no-build --no-deps --force-recreate ` 重新拉起对应 compose service;其中 `met-nonlinear` 当前因为 registered Dockerfile 和 long-running service contract 不一致而 live deploy blocked。`k3sctl-adapter` 是基础设施控制桥,只做 plan/dry-run,真实生产部署需要 supervisor 单独确认。`frontend --env prod` 使用同一 Compose artifact consumer,流式拉取 `unidesk/frontend:`,retag 为 `unidesk-frontend` 和 `unidesk-frontend:`,写入 `UNIDESK_FRONTEND_DEPLOY_*`,只 recreate `frontend` service,并验证 image label 与 `/health.deploy.commit`。`frontend --env dev`、`decision-center`、`mdtodo`、`claudeqq` 和 dev `code-queue` 的 k3s 路径会在 D601 上验证 commit image、导入 native k3s containerd、更新 Deployment image/env/annotations,并通过 Kubernetes API service proxy 验证 `/health` 中的 live commit 与 requested commit;dev frontend 还会在 rollout 前把主 server `config.json.auth` 同步到 `unidesk-dev` Secret/ConfigMap。`decision-center --env dev` 落到 `unidesk-dev/decision-center-dev`,prod 落到 `unidesk/decision-center`;`mdtodo` 和 `claudeqq` 使用同样的 dev 后 prod k3s consumer 结构。`code-queue --env dev` 只更新 `unidesk-dev` 中的 scheduler/read/write/provider-egress-proxy dev Deployments,prod 没有 consumer target。D601 direct Compose consumer 与 k3s-managed consumer 的区别是:前者只接触 D601 Docker/Compose 项目和私有 backend health,不创建 Kubernetes 对象;后者只通过 native k3s Deployment/Service、containerd import 和 Kubernetes API service proxy 验证 live commit。回滚信息通过同一 artifact consumer 的 `rollback` 字段暴露,提示操作者重新对一个旧 commit 运行相同命令,而不是切回 legacy maintenance-channel 构建。 `status` 和 `health` 通过: @@ -165,7 +165,7 @@ Main-server direct production dry-runs for the user-service matrix must be enoug | `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 | +| `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`, redacted `runtimeSecrets` source contract, 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. diff --git a/docs/reference/cicd-standardization.md b/docs/reference/cicd-standardization.md index c3566c20..9920bc96 100644 --- a/docs/reference/cicd-standardization.md +++ b/docs/reference/cicd-standardization.md @@ -70,7 +70,7 @@ Main-server Compose user services are normal source-build artifacts even though | Service | Producer | Artifact | Consumer | Dev validation | Prod validation | Blocker | | --- | --- | --- | --- | --- | --- | --- | -| `baidu-netdisk` | `ci publish-user-service --service baidu-netdisk` from `src/components/microservices/baidu-netdisk/Dockerfile` | `127.0.0.1:5000/unidesk/baidu-netdisk:` | master-server Compose service `baidu-netdisk`, container `baidu-netdisk-backend` | `deploy plan/apply --env dev --service baidu-netdisk` consumes the registry artifact and verifies labels plus health; dry-run is acceptable while the artifact or registry is absent | `deploy plan/apply --env prod --service baidu-netdisk` recreates only `baidu-netdisk` with `--no-build --no-deps --force-recreate`, then verifies image labels, `/health.deploy.commit`, requested commit and auth health | D601 registry artifact must exist; live apply also requires non-empty Baidu client id, client secret and token key plus logged-in auth health | +| `baidu-netdisk` | `ci publish-user-service --service baidu-netdisk` from `src/components/microservices/baidu-netdisk/Dockerfile` | `127.0.0.1:5000/unidesk/baidu-netdisk:` | master-server Compose service `baidu-netdisk`, container `baidu-netdisk-backend` | `deploy plan/apply --env dev --service baidu-netdisk` consumes the registry artifact and must expose the redacted `runtimeSecrets` source contract; dry-run is acceptable while the artifact, registry or secret source condition is absent | `deploy plan/apply --env prod --service baidu-netdisk` recreates only `baidu-netdisk` with `--no-build --no-deps --force-recreate`, then verifies image labels, `/health.deploy.commit`, requested commit and auth health; current prod artifact/health evidence is aligned | D601 registry artifact must exist; dev live apply remains blocked until canonical Compose env source has non-empty Baidu client id, client secret and token key plus logged-in auth health | | `project-manager` | `ci publish-user-service --service project-manager` from `src/components/microservices/project-manager/Dockerfile` | `127.0.0.1:5000/unidesk/project-manager:` | master-server Compose service `project-manager`, container `project-manager-backend` | `deploy plan/apply --env dev --service project-manager` consumes the registry artifact and verifies labels plus health; dry-run is acceptable while the artifact or registry is absent | `deploy plan/apply --env prod --service project-manager` recreates only `project-manager` with `--no-build --no-deps --force-recreate`, then verifies image labels, `/health.deploy.commit` and requested commit | D601 registry artifact must exist before live dev or prod apply | Focused smoke for this class is intentionally narrow: health, running image labels/digest, live `deploy.commit` / `deploy.requestedCommit`, and one private proxy API check such as `baidu-netdisk /api/transfers?limit=20` or `project-manager /api/projects`. Full e2e, Playwright, broad `check`, public-port probing and unrelated service restarts are outside this lane. @@ -111,6 +111,7 @@ This matrix is the single review surface for the remaining D601 service lane. It | `met-nonlinear` | D601 `unidesk-direct` GPU/business execution service in Docker Compose; control path is backend-core -> provider-gateway private HTTP proxy -> D601 loopback `/health` and `/api/`. | `CI.json` source-build supported through `ci publish-user-service --service met-nonlinear`; cataloged artifact uses `docker/unidesk/Dockerfile.ml` from `https://github.com/pikasTech/met_nonlinear`. | D601 direct Compose consumer is plan/dry-run only for service/container `met-nonlinear-ts`; dry-run exposes the no-build pull-only shape but returns `runtime-verification-blocked`. | Dry-run/read-only only. `deploy apply --env dev --service met-nonlinear --dry-run` must remain blocked until the running service image contract matches the published artifact. | Not authorized. Prod dry-run must remain `runtime-verification-blocked`; live prod apply is unsupported. | Published artifact is the ML image contract while the long-running service is `met-nonlinear-ts`, so CD cannot prove the running container image label equals the requested commit. | Split the TS server artifact from the ML image or publish a labeled artifact that exactly matches `met-nonlinear-ts`; then add live commit proof before enabling apply. | | `k3sctl-adapter` | UniDesk-managed D601 direct Compose control bridge, outside the native k3s fault domain; it is the control path for k3s-managed services and must not be moved into k3s. | `CI.json` source-build supported through `ci publish-user-service --service k3sctl-adapter`; artifact is `127.0.0.1:5000/unidesk/k3sctl-adapter:` from the UniDesk Dockerfile. | Artifact consumer exposes plan/dry-run only for service/container `k3sctl-adapter`; live replacement is supervisor-only because replacing the bridge can remove the repair path for k3s. | No normal dev target. DEV acceptance is read-only bridge health, service catalog/proxy checks and dry-run contract review only. | Dry-run/read-only only in this lane. Real prod replacement requires explicit supervisor confirmation, rollback proof and out-of-band recovery access. | Must remain recoverable while k3s may be broken; worker automation must not self-replace or k3s-manage the bridge. | Write a supervised bridge-upgrade runbook with rollback and out-of-band access checks; keep CLI dry-run as the standard preflight. | | `code-queue` | Production execution plane is D601 native k3s (`unidesk` namespace) behind `k3sctl-adapter`; dev execution plane is `unidesk-dev` scheduler/read/write/provider-egress-proxy. Main-server `code-queue-mgr` is a separate control-plane sidecar. | `CI.json` source-build supported through `ci publish-user-service --service code-queue` for dev image validation only; artifact is `127.0.0.1:5000/unidesk/code-queue:`. | Reviewed dev-only k3s artifact consumer updates only `unidesk-dev` Code Queue objects. `deploy plan --env prod --service code-queue` and `artifact-registry deploy-service --env prod --service code-queue` must stay unsupported. | Allowed only as dry-run/source/contract evidence here; a later human-approved dev live apply may consume the artifact into `unidesk-dev` outside the running Code Queue task. | Not implemented and not authorized. No production artifact deploy, manifest mutation, scheduler/runner restart, interrupt or cancel is allowed. | Production still has hostPath/source and active-run safety boundaries; self-deploy would couple the deployment actor to the target being replaced. | Keep contract tests and dev dry-run coverage; design a separate supervisor-approved production CD consumer before any prod mutation is considered. | +| `decision-center` | D601 native k3s user service; dev runs `unidesk-dev/decision-center-dev`, prod runs `unidesk/decision-center`, both behind backend-core -> provider-gateway -> k3sctl-adapter -> Kubernetes API service proxy. | `CI.json` source-build supported through `ci publish-user-service --service decision-center`; artifact is `127.0.0.1:5000/unidesk/decision-center:` from the UniDesk Dockerfile. | Dev and prod are reviewed D601 k3s artifact consumers. Desired state, live health, and registry artifact must point at the same commit; drift where live is newer than `deploy.json` is corrected by repinning `deploy.json`, not by redeploying. | Closed for artifact CD when `deploy plan --env dev --service decision-center` is no-build and health reports matching `deploy.commit` / `deploy.requestedCommit`. Focused product gates remain record CRUD, diary lifecycle and frontend Decision Center visibility. | Closed for artifact CD when `deploy plan --env prod --service decision-center` is no-build and health reports matching `deploy.commit` / `deploy.requestedCommit`. Remaining acceptance is manual UI/product verification: health, records, diary editor, frontend page, no public business ports and live commit/artifact information. | Product completeness and manual UI acceptance can remain open, but they are not deployment drift. Registry artifact digest and health commit are the release evidence. | Keep `scripts/decision-center-desired-state-contract-test.ts` in the lightweight script gate so future desired-state edits cannot reintroduce source-build or stale-commit drift. | Minimum evidence for this lane is: @@ -123,6 +124,7 @@ Minimum evidence for this lane is: | MET Nonlinear blocked dry-run | `bun scripts/cli.ts deploy apply --env dev --service met-nonlinear --dry-run` | | k3s control bridge dry-run | `bun scripts/cli.ts deploy apply --env prod --service k3sctl-adapter --dry-run` | | CI producer preflight | `bun scripts/cli.ts ci publish-user-service --service --commit --dry-run` | +| Decision Center desired/live no-build drift guard | `bun scripts/decision-center-desired-state-contract-test.ts` | ### Upstream Image Evidence @@ -255,11 +257,11 @@ This matrix describes the next promotion stage after dry-run coverage is in plac | `backend-core` | `master` | source-build supported through `ci publish-backend-core` | dev + prod artifact consumer | dev artifact rollout to `unidesk-dev/backend-core-dev` with no CD compile | prod artifact recreate with live commit proof | CI resource, registry artifact, and runtime health evidence | `GPT-5.5` | | `code-queue` | `master` | source-build supported, dev-only | dev-only k3s consumer | dev artifact validation for `unidesk-dev` scheduler/read/write/provider-egress-proxy | not implemented; must remain unsupported | production boundary, hostPath/source contract, scheduler/egress dependency health | `GPT-5.5` | | `frontend` | `master` | source-build supported | dev + prod artifact consumer | commit-pinned dev rollout and `/health.deploy.commit` | commit-pinned prod recreate and UI route verification | none beyond standard artifact/CD checks | `GPT-5.5` | -| `baidu-netdisk` | `master` | source-build supported | dev + prod artifact consumer | pull-only dev validation plus auth and proxy checks | pull-only prod recreate plus live commit and proxy checks | secret presence and `/health.auth` gate | `GPT-5.5` | +| `baidu-netdisk` | `master` | source-build supported | dev + prod artifact consumer | pull-only dev validation is diagnosable through `runtimeSecrets.secretSource`, `requiredSecretsPresent`, `missingSecretKeys` and `recommendedAction`; live dev apply waits for the secret source condition | prod artifact and health are aligned; further prod action is only focused post-apply proxy/auth verification after operator approval | canonical Compose env secret source and `/health.auth` gate; no manual secret operation in worker tasks | `GPT-5.5` | | `project-manager` | `master` | source-build supported | dev + prod artifact consumer | dev artifact validation with `/api/projects` | prod artifact validation with live commit proof | none beyond standard artifact/CD checks | `MiniMax` for dry-run/reporting, `GPT-5.5` for release sign-off | | `oa-event-flow` | `master` | source-build supported | dev + prod artifact consumer | dev artifact validation with `/api/diagnostics` | prod artifact validation with live commit proof | none beyond standard artifact/CD checks | `MiniMax` for dry-run/reporting, `GPT-5.5` for release sign-off | | `todo-note` | `master` | external source-build supported | dev + prod artifact consumer | dev recreate with PostgreSQL-backed deploy metadata | prod recreate with matching `deploy.commit` and `deploy.requestedCommit` | external repo fetch and runtime metadata consistency | `DeepSeek` for digesting external-source evidence, `GPT-5.5` for final gate | -| `decision-center` | `master` | source-build supported | dev + prod k3s consumer | dev gate with record CRUD, diary lifecycle, doc-number uniqueness and frontend visibility | manual prod acceptance after dev gate; verify health, records, diary editor and live commit | doc-management completeness, PostgreSQL truth, no public business ports | `GPT-5.5` | +| `decision-center` | `master` | source-build supported | dev + prod k3s consumer closed when desired/live/artifact commit match and dry-run stays no-build | dev artifact CD closed; remaining dev acceptance is focused record CRUD, diary lifecycle, doc-number uniqueness and frontend visibility | prod artifact CD closed; remaining prod acceptance is manual UI/product verification after health/live commit proof | doc-management completeness, PostgreSQL truth and UI acceptance; no deployment drift when desired/live/artifact are aligned | `GPT-5.5` | | `mdtodo` | `master` | source-build supported | dev + prod k3s consumer | dev rollout with deployment metadata and `/health` or `/live` proof | prod rollout with service proxy verification and live commit proof | no NodePort/hostPort/public backend exposure | `MiniMax` for prompt prep, `GPT-5.5` for approval | | `claudeqq` | `master` | source-build supported | dev + prod k3s consumer | dev rollout with Deployment metadata and health via Kubernetes API proxy | prod rollout with same commit-pinned artifact contract | NapCat/backend port exposure must stay private | `MiniMax` for prompt prep, `GPT-5.5` for approval | | `findjob` | `master` | source-build supported | dev + prod direct Compose consumer | pull-only dev validation on D601 with image labels and `/api/health` | pull-only prod recreate with live commit proof | target-side compose health/labels only, no public business ports | `DeepSeek` for dry-run matrix drafting, `GPT-5.5` for final gate | diff --git a/docs/reference/deploy.md b/docs/reference/deploy.md index 13a8fd41..03b892db 100644 --- a/docs/reference/deploy.md +++ b/docs/reference/deploy.md @@ -112,7 +112,7 @@ Production `code-queue-mgr` is a separate main-server Compose sidecar artifact c `bun scripts/cli.ts deploy plan --env dev [--service ]` reads `origin/master:deploy.json#environments.dev` and prints a dry-run environment plan without checking or mutating live runtime resources. `deploy check --env dev` uses the same dry-run environment plan. `--env prod` is available for parity as a dry-run planning path; it reads `origin/master:deploy.json#environments.prod` and must not use a dirty local `deploy.json`. -Environment plan output must be sufficient to review the artifact matrix without running a live apply. Each service item includes `deploymentPath`, `artifactConsumer.consumerKind`, `artifactConsumer.registryImage`, `artifactConsumer.registry`, `artifactConsumer.source`, `artifactConsumer.build`, `artifactConsumer.noRuntimeSourceBuild`, `artifactConsumer.dryRunOnly`, `target`, `validation` and `liveApply` where relevant. `consumerKind=d601-direct-compose` means the reviewed consumer touches only the D601 Docker/Compose service and private health path; `consumerKind=d601-k3s-managed` means the reviewed consumer imports the artifact into native k3s/containerd and verifies through the Kubernetes API service proxy; `consumerKind=main-server-compose` means the reviewed consumer streams or loads the D601 artifact into the main-server Compose service; `consumerKind=d601-dev-target-side-build` is retained only as a legacy classification and should not appear for backend-core. Artifact consumer plan items must explicitly report `noRuntimeSourceBuild=true`, expose registry/source/build boundaries including digest provenance, and list forbidden build/public exposure actions. Blocked or gated services must keep structured `dryRunOnly` / `blockedReason` output, for example `met-nonlinear` `runtime-verification-blocked` and `k3sctl-adapter` supervisor-only production apply. +Environment plan output must be sufficient to review the artifact matrix without running a live apply. Each service item includes `deploymentPath`, `artifactConsumer.consumerKind`, `artifactConsumer.registryImage`, `artifactConsumer.registry`, `artifactConsumer.source`, `artifactConsumer.build`, `artifactConsumer.noRuntimeSourceBuild`, `artifactConsumer.dryRunOnly`, `target`, `validation` and `liveApply` where relevant. `consumerKind=d601-direct-compose` means the reviewed consumer touches only the D601 Docker/Compose service and private health path; `consumerKind=d601-k3s-managed` means the reviewed consumer imports the artifact into native k3s/containerd and verifies through the Kubernetes API service proxy; `consumerKind=main-server-compose` means the reviewed consumer streams or loads the D601 artifact into the main-server Compose service; `consumerKind=d601-dev-target-side-build` is retained only as a legacy classification and should not appear for backend-core. Artifact consumer plan items must explicitly report `noRuntimeSourceBuild=true`, expose registry/source/build boundaries including digest provenance, and list forbidden build/public exposure actions. Services with runtime secret gates, currently `baidu-netdisk`, must also expose a redacted `artifactConsumer.runtimeSecrets` contract with `secretSource`, `requiredSecretsPresent`, `missingSecretKeys` and `recommendedAction`; this contract may report key names, booleans and lengths only, never secret values. Blocked or gated services must keep structured `dryRunOnly` / `blockedReason` output, for example `met-nonlinear` `runtime-verification-blocked` and `k3sctl-adapter` supervisor-only production apply. For `--env dev --service code-queue`, the environment plan must also expose a `boundary` block that separates the CI producer from the dev CD consumer. CI is allowed to publish only `127.0.0.1:5000/unidesk/code-queue:` plus digest/label evidence. DEV CD may consume that artifact only for `unidesk-dev` Code Queue scheduler/read/write/provider-egress-proxy objects after an operator reviews the dry-run. For `--env prod --service code-queue`, the service item must remain `deploymentPath=unsupported`, `artifactConsumer.consumerKind=unsupported`, `target.deployCommandShape=none` and `liveApply.allowed=false`; it must not expose production k3s as an executable target. The prod boundary must state that production Code Queue CD needs a future supervisor-approved design and that this runner cannot self-deploy, mutate the production namespace, restart scheduler/runner, or interrupt/cancel tasks. @@ -145,7 +145,7 @@ The exception is narrow: - CD must not run Rust compilation, Docker build, Compose build or `server rebuild backend-core`. - The legacy `artifact-registry deploy-backend-core` compatibility entry is deprecated and disabled as a standard entrypoint; use `deploy apply --env prod --service backend-core --commit ` so the common artifact-consumer guardrails execute first. - The pushed Git commit remains the version source of truth. The image registry is a content cache and transfer boundary, not a replacement for `deploy.json` or Git. -- `baidu-netdisk` is the first main-server direct user-service sample for the same split: CI publishes `127.0.0.1:5000/unidesk/baidu-netdisk:` from `src/components/microservices/baidu-netdisk/Dockerfile`; dev validation and prod CD both pull that artifact, retag `baidu-netdisk`, recreate only `baidu-netdisk` with `--no-build --no-deps --force-recreate`, and verify image labels plus `/health.deploy.commit`. +- `baidu-netdisk` is the first main-server direct user-service sample for the same split: CI publishes `127.0.0.1:5000/unidesk/baidu-netdisk:` from `src/components/microservices/baidu-netdisk/Dockerfile`; dev validation and prod CD both pull that artifact, retag `baidu-netdisk`, recreate only `baidu-netdisk` with `--no-build --no-deps --force-recreate`, and verify image labels plus `/health.deploy.commit`. The current prod lane is aligned when the artifact, running image and health commit match; dev apply remains gated until the canonical Compose env secret source reports the three Baidu keys present through the redacted `runtimeSecrets` contract. - `frontend` is the UniDesk UI artifact sample: CI publishes `127.0.0.1:5000/unidesk/frontend:` from `src/components/frontend/Dockerfile`; dev CD imports that artifact into native k3s `frontend-dev`, prod CD retags it as `unidesk-frontend` for the master-server Compose service, and both paths verify image labels plus `/health.deploy.commit`. - `findjob` and `pipeline` are D601 direct Docker/Compose artifact consumers: CD runs on D601 through the existing provider-gateway/SSH maintenance bridge, verifies `127.0.0.1:5000/unidesk/:` labels, writes deploy env/labels, and recreates only the target Compose service with `--no-build --no-deps --force-recreate`. - `met-nonlinear` has a D601 direct dry-run/plan contract, but live artifact deploy is blocked until the long-running `met-nonlinear-ts` image contract is separated from the ML image Dockerfile contract or otherwise proves the running container image label matches the requested commit. @@ -200,7 +200,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. -Baidu Netdisk is the main-server `unidesk-direct` sample for artifact CD and a dependency of the PGDATA-to-Baidu-Netdisk backup path. Controlled dev validation and prod CD use the D601 registry artifact consumer: it verifies `unidesk/baidu-netdisk:` exists in the registry, streams the image to the main server through provider-gateway Host SSH, retags `baidu-netdisk` and `baidu-netdisk:`, stamps `UNIDESK_BAIDU_NETDISK_DEPLOY_*` in the canonical Compose env file, recreates only Compose service `baidu-netdisk`, and verifies container health, image labels, service id, `/health.deploy.commit`, and `/health.auth`. Live apply must fail or return degraded before success if `UNIDESK_BAIDU_NETDISK_CLIENT_ID`, `UNIDESK_BAIDU_NETDISK_CLIENT_SECRET`, or `UNIDESK_BAIDU_NETDISK_TOKEN_KEY` is absent from the controlled env source, or if `/health.auth.configured`, `clientIdConfigured`, `clientSecretConfigured`, `tokenKeyConfigured`, or `loggedIn` is not true after recreate. Dry-run only reports that these secret presences and auth fields are required and pending live check; it must not read or print secret values. It must not use `server rebuild baidu-netdisk`, mutable tags, dirty worktrees, hand-built images, or public `4244` exposure as deployment truth. +Baidu Netdisk is the main-server `unidesk-direct` sample for artifact CD and a dependency of the PGDATA-to-Baidu-Netdisk backup path. Controlled dev validation and prod CD use the D601 registry artifact consumer: it verifies `unidesk/baidu-netdisk:` exists in the registry, streams the image to the main server through provider-gateway Host SSH, retags `baidu-netdisk` and `baidu-netdisk:`, stamps `UNIDESK_BAIDU_NETDISK_DEPLOY_*` in the canonical Compose env file, recreates only Compose service `baidu-netdisk`, and verifies container health, image labels, service id, `/health.deploy.commit`, and `/health.auth`. Live apply must fail or return degraded before success if `UNIDESK_BAIDU_NETDISK_CLIENT_ID`, `UNIDESK_BAIDU_NETDISK_CLIENT_SECRET`, or `UNIDESK_BAIDU_NETDISK_TOKEN_KEY` is absent from the controlled env source, or if `/health.auth.configured`, `clientIdConfigured`, `clientSecretConfigured`, `tokenKeyConfigured`, or `loggedIn` is not true after recreate. Dry-run reports `secretSource`, `requiredSecretsPresent`, `missingSecretKeys` and `recommendedAction` so a missing dev secret source is diagnosable before live apply; it must not print secret values. It must not use `server rebuild baidu-netdisk`, mutable tags, dirty worktrees, hand-built images, or public `4244` exposure as deployment truth. For PGDATA-to-Baidu-Netdisk incident review, the no-authorization read-only boundary is limited to `server status`, `schedule list`, `schedule get`, `schedule runs`, `microservice status/health baidu-netdisk`, `microservice proxy baidu-netdisk /api/auth/status --raw`, and `microservice proxy baidu-netdisk '/api/transfers?limit=20' --raw`. These commands may report `failureKind=target-stack-not-running` when `unidesk-backend-core`, `unidesk-database`, or `baidu-netdisk-backend` is absent, especially when only `*.verify-*` containers are visible; that state is an infrastructure blocker, not a successful empty backup history. Recovery actions such as restoring non-empty Baidu secrets, `server start`, `server rebuild backend-core`, `server rebuild baidu-netdisk`, `deploy apply --env prod --service baidu-netdisk`, `schedule run`, or `schedule retry-run` can affect production or trigger a real backup and require explicit operator authorization. diff --git a/scripts/artifact-consumer-dry-run-matrix-test.ts b/scripts/artifact-consumer-dry-run-matrix-test.ts index 697889a4..47071428 100644 --- a/scripts/artifact-consumer-dry-run-matrix-test.ts +++ b/scripts/artifact-consumer-dry-run-matrix-test.ts @@ -152,6 +152,19 @@ for (const item of serviceCases) { `${item.serviceId} dry-run must narrow Compose recreate command`, target, ); + if (item.serviceId === "baidu-netdisk") { + const runtimeSecrets = asRecord(plan.runtimeSecrets, "baidu-netdisk dry-run runtimeSecrets"); + const secretSource = asRecord(runtimeSecrets.secretSource, "baidu-netdisk dry-run secretSource"); + const requirements = asArray(runtimeSecrets.requirements, "baidu-netdisk dry-run requirements").map((requirement, index) => asRecord(requirement, `baidu-netdisk dry-run requirement ${index}`)); + assertCondition(runtimeSecrets.check === "runtime-secret-presence", "baidu-netdisk dry-run should expose runtime secret contract", runtimeSecrets); + assertCondition(secretSource.kind === "compose-env-file", "baidu-netdisk dry-run should expose canonical secret source kind", secretSource); + assertCondition(secretSource.valuesPrinted === false && runtimeSecrets.valuesPrinted === false, "baidu-netdisk dry-run must not print secret values", runtimeSecrets); + assertCondition(typeof runtimeSecrets.requiredSecretsPresent === "boolean", "baidu-netdisk requiredSecretsPresent should be boolean", runtimeSecrets); + assertCondition(Array.isArray(runtimeSecrets.missingSecretKeys), "baidu-netdisk missingSecretKeys should be structured", runtimeSecrets); + assertCondition(typeof runtimeSecrets.recommendedAction === "string" && runtimeSecrets.recommendedAction.length > 0, "baidu-netdisk recommendedAction should be explicit", runtimeSecrets); + assertCondition(requirements.length === 3, "baidu-netdisk should list three required source secret keys", requirements); + assertCondition(requirements.every((requirement) => requirement.valuePrinted === false), "baidu-netdisk should never mark values printed", requirements); + } 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); diff --git a/scripts/baidu-netdisk-artifact-guard-contract-test.ts b/scripts/baidu-netdisk-artifact-guard-contract-test.ts index 667d3237..959bb95a 100644 --- a/scripts/baidu-netdisk-artifact-guard-contract-test.ts +++ b/scripts/baidu-netdisk-artifact-guard-contract-test.ts @@ -1,6 +1,7 @@ import { baiduNetdiskAuthHealthGateStatus, baiduNetdiskRuntimeSecretRequirements, + runtimeSecretContractFromEnvText, runtimeSecretPresenceFromEnvText, } from "./src/artifact-registry"; @@ -19,6 +20,21 @@ assertCondition(present.every((item) => item.present), "all baidu-netdisk secret assertCondition(present.map((item) => item.length).join(",") === "31,30,64", "presence reports only lengths", present); assertCondition(!JSON.stringify(present).includes("0123456789abcdef"), "secret values must not be exposed", present); +const presentContract = runtimeSecretContractFromEnvText(secretEnvText, baiduNetdiskRuntimeSecretRequirements, { + path: "/root/unidesk/.state/docker-compose.env", + exists: true, + workDir: "/root/unidesk", + composeEnvFile: ".state/docker-compose.env", + composeService: "baidu-netdisk", + containerName: "baidu-netdisk-backend", +}); +assertCondition(presentContract.secretSource.kind === "compose-env-file", "secret source should name canonical compose env file", presentContract); +assertCondition(presentContract.requiredSecretsPresent === true, "present contract should pass", presentContract); +assertCondition(presentContract.missingSecretKeys.length === 0, "present contract should not list missing keys", presentContract); +assertCondition(presentContract.recommendedAction === "none", "present contract should recommend no action", presentContract); +assertCondition(presentContract.valuesPrinted === false, "present contract must not print values", presentContract); +assertCondition(!JSON.stringify(presentContract).includes("clientsecret"), "contract must not expose fake secret values", presentContract); + const missing = runtimeSecretPresenceFromEnvText( "UNIDESK_BAIDU_NETDISK_CLIENT_ID=clientid-clientid-clientid-0000\n", baiduNetdiskRuntimeSecretRequirements, @@ -30,6 +46,30 @@ assertCondition( missing, ); +const missingContract = runtimeSecretContractFromEnvText( + "UNIDESK_BAIDU_NETDISK_CLIENT_ID=clientid-clientid-clientid-0000\n", + baiduNetdiskRuntimeSecretRequirements, + { + path: "/root/unidesk/.state/docker-compose.env", + exists: true, + workDir: "/root/unidesk", + composeEnvFile: ".state/docker-compose.env", + composeService: "baidu-netdisk", + containerName: "baidu-netdisk-backend", + }, +); +assertCondition(missingContract.requiredSecretsPresent === false, "missing contract should fail", missingContract); +assertCondition( + missingContract.missingSecretKeys.join(",") === "UNIDESK_BAIDU_NETDISK_CLIENT_SECRET,UNIDESK_BAIDU_NETDISK_TOKEN_KEY", + "missing contract should expose missing source keys", + missingContract, +); +assertCondition( + String(missingContract.recommendedAction).includes("canonical Compose env file"), + "missing contract should recommend the canonical source", + missingContract, +); + const healthy = baiduNetdiskAuthHealthGateStatus({ auth: { configured: true, @@ -61,10 +101,13 @@ process.stdout.write(`${JSON.stringify({ ok: true, checks: [ "runtime secret presence reports booleans and lengths only", + "runtime secret contract exposes secretSource/requiredSecretsPresent/missingSecretKeys/recommendedAction without values", "missing Baidu Netdisk env cannot pass the deploy contract", "auth health gate requires configured/clientId/clientSecret/tokenKey/loggedIn", ], present, + presentContract, missing: missing.map((item) => ({ ...item, length: item.length })), + missingContract, degraded, }, null, 2)}\n`); diff --git a/scripts/deploy-artifact-matrix-contract-test.ts b/scripts/deploy-artifact-matrix-contract-test.ts index fdd1b2ca..c3f4b204 100644 --- a/scripts/deploy-artifact-matrix-contract-test.ts +++ b/scripts/deploy-artifact-matrix-contract-test.ts @@ -58,6 +58,20 @@ function assertMainServerComposeConsumer( assertCondition(target.containerName === expectedContainerName, `${serviceId} container mismatch`, target); assertCondition(listIncludes(target.forbiddenActions, "docker build"), `${serviceId} plan should forbid docker build`, target); assertCondition(listIncludes(target.forbiddenActions, "docker compose build"), `${serviceId} plan should forbid compose build`, target); + if (serviceId === "baidu-netdisk") { + const runtimeSecrets = asRecord(artifact.runtimeSecrets, `${serviceId} ${environment} runtimeSecrets`); + const secretSource = asRecord(runtimeSecrets.secretSource, `${serviceId} ${environment} secretSource`); + const requirements = Array.isArray(runtimeSecrets.requirements) ? runtimeSecrets.requirements.map((item, index) => asRecord(item, `${serviceId} ${environment} requirement ${index}`)) : []; + assertCondition(runtimeSecrets.check === "runtime-secret-presence", `${serviceId} should expose secret presence check`, runtimeSecrets); + assertCondition(secretSource.kind === "compose-env-file", `${serviceId} should name compose env secret source`, secretSource); + assertCondition(secretSource.valuesPrinted === false && runtimeSecrets.valuesPrinted === false, `${serviceId} must not print secret values`, runtimeSecrets); + assertCondition(typeof runtimeSecrets.requiredSecretsPresent === "boolean", `${serviceId} requiredSecretsPresent should be boolean`, runtimeSecrets); + assertCondition(Array.isArray(runtimeSecrets.missingSecretKeys), `${serviceId} missingSecretKeys should be an array`, runtimeSecrets); + assertCondition(typeof runtimeSecrets.recommendedAction === "string" && runtimeSecrets.recommendedAction.length > 0, `${serviceId} recommendedAction should be explicit`, runtimeSecrets); + assertCondition(requirements.length === 3, `${serviceId} should list three required source secrets`, runtimeSecrets); + assertCondition(requirements.every((item) => item.valuePrinted === false), `${serviceId} requirements must not print values`, requirements); + assertCondition(!JSON.stringify(runtimeSecrets).includes("0123456789abcdef"), `${serviceId} must not leak secret-looking values`, runtimeSecrets); + } } assertMainServerComposeConsumer("dev", "baidu-netdisk", "baidu-netdisk", "baidu-netdisk-backend"); diff --git a/scripts/src/artifact-registry.ts b/scripts/src/artifact-registry.ts index 3331e1cc..2a8dab00 100644 --- a/scripts/src/artifact-registry.ts +++ b/scripts/src/artifact-registry.ts @@ -166,6 +166,7 @@ interface AuthHealthGate { interface ComposeArtifactRuntime { workDir: string; composeFile: string; + composeEnvFile: string; envFile: string; project: string; command: string[]; @@ -178,6 +179,36 @@ export interface RuntimeSecretPresence { length: number; } +export interface RuntimeSecretSource { + kind: "compose-env-file"; + path: string; + exists: boolean; + configSource: "config.providerGateway.upgrade.hostProjectRoot + config.providerGateway.upgrade.composeEnvFile"; + workDir: string; + composeEnvFile: string; + composeService: string | null; + containerName: string | null; + valuesPrinted: false; +} + +export interface RuntimeSecretRequirementStatus { + sourceEnvName: string; + containerEnvName: string; + present: boolean; + valuePrinted: false; +} + +export interface RuntimeSecretContract { + check: "runtime-secret-presence"; + secretSource: RuntimeSecretSource; + requiredSecretsPresent: boolean; + missingSecretKeys: string[]; + recommendedAction: string; + valuesPrinted: false; + requirements: RuntimeSecretRequirementStatus[]; + dryRunDisposition: "not-required" | "ready-for-live-apply" | "secret-source-blocked"; +} + function todoNoteHealthProbeCommand(): string { return "bun -e \"fetch('http://127.0.0.1:4211/api/health').then(async r=>{const text=await r.text(); let body; try{body=JSON.parse(text)}catch{body={ok:r.ok,raw:text}}; const deploy=body.deploy&&typeof body.deploy==='object'&&!Array.isArray(body.deploy)?body.deploy:{}; body.deploy={...deploy,serviceId:deploy.serviceId||process.env.UNIDESK_DEPLOY_SERVICE_ID||'todo-note',ref:deploy.ref||process.env.UNIDESK_DEPLOY_REF||'',repo:deploy.repo||process.env.UNIDESK_DEPLOY_REPO||'',commit:deploy.commit||process.env.UNIDESK_DEPLOY_COMMIT||'',requestedCommit:deploy.requestedCommit||process.env.UNIDESK_DEPLOY_REQUESTED_COMMIT||''}; console.log(JSON.stringify(body)); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\""; } @@ -1553,13 +1584,12 @@ function composeLockScript(innerScript: string): string { ].join("; "); } -function parseEnvFile(raw: string): Record { - const values: Record = {}; +function findEnvValueLength(raw: string, key: string): number { for (const line of raw.split(/\r?\n/u)) { if (!line.trim() || line.trimStart().startsWith("#")) continue; const index = line.indexOf("="); if (index <= 0) continue; - const key = line.slice(0, index); + if (line.slice(0, index) !== key) continue; let value = line.slice(index + 1); if (value.startsWith("\"") && value.endsWith("\"")) { try { @@ -1568,20 +1598,19 @@ function parseEnvFile(raw: string): Record { value = value.slice(1, -1); } } - values[key] = value; + return value.length; } - return values; + return 0; } export function runtimeSecretPresenceFromEnvText(envText: string, requirements: RuntimeSecretRequirement[]): RuntimeSecretPresence[] { - const values = parseEnvFile(envText); return requirements.map((requirement) => { - const value = values[requirement.sourceEnvName] ?? ""; + const length = findEnvValueLength(envText, requirement.sourceEnvName); return { sourceEnvName: requirement.sourceEnvName, containerEnvName: requirement.containerEnvName, - present: value.length > 0, - length: value.length, + present: length > 0, + length, }; }); } @@ -1602,10 +1631,97 @@ function composeArtifactRuntime(config: UniDeskConfig, target: ArtifactConsumerT const compose = target.compose; const workDir = compose.workDir ?? config.providerGateway.upgrade.hostProjectRoot; const composeFile = compose.composeFile ?? config.providerGateway.upgrade.composeFile; - const envFile = join(workDir, config.providerGateway.upgrade.composeEnvFile); + const composeEnvFile = config.providerGateway.upgrade.composeEnvFile; + const envFile = join(workDir, composeEnvFile); const project = compose.projectHint ?? (config.providerGateway.upgrade.composeProject || config.docker.projectName); const command = ["docker", "compose", "--env-file", envFile, "-f", join(workDir, composeFile), "-p", project]; - return { workDir, composeFile, envFile, project, command }; + return { workDir, composeFile, composeEnvFile, envFile, project, command }; +} + +function runtimeSecretSourceForTarget(config: UniDeskConfig, target: ArtifactConsumerTarget): RuntimeSecretSource { + const runtime = composeArtifactRuntime(config, target); + return { + kind: "compose-env-file", + path: runtime.envFile, + exists: existsSync(runtime.envFile), + configSource: "config.providerGateway.upgrade.hostProjectRoot + config.providerGateway.upgrade.composeEnvFile", + workDir: runtime.workDir, + composeEnvFile: runtime.composeEnvFile, + composeService: target.compose?.serviceName ?? null, + containerName: target.compose?.containerName ?? null, + valuesPrinted: false, + }; +} + +function runtimeSecretRecommendedAction(missing: string[]): string { + if (missing.length === 0) return "none"; + return "Restore the missing source env keys in the canonical Compose env file without printing values, then rerun deploy apply --env --service --dry-run before any live apply."; +} + +function runtimeSecretRecommendedActionForService(contract: RuntimeSecretContract, environment: ArtifactDeployEnvironment, serviceId: string): RuntimeSecretContract { + if (contract.requiredSecretsPresent) return { ...contract, recommendedAction: "none" }; + return { + ...contract, + recommendedAction: `Restore ${contract.missingSecretKeys.join(", ")} in the canonical Compose env file without printing values, then rerun deploy apply --env ${environment} --service ${serviceId} --dry-run before any live apply.`, + }; +} + +function runtimeSecretContractFromPresence( + source: RuntimeSecretSource, + requirements: RuntimeSecretRequirement[], + presence: RuntimeSecretPresence[], +): RuntimeSecretContract { + const missingSecretKeys = presence.filter((item) => !item.present).map((item) => item.sourceEnvName); + const requiredSecretsPresent = requirements.length === 0 || (source.exists && missingSecretKeys.length === 0); + return { + check: "runtime-secret-presence", + secretSource: source, + requiredSecretsPresent, + missingSecretKeys, + recommendedAction: runtimeSecretRecommendedAction(missingSecretKeys), + valuesPrinted: false, + requirements: presence.map((item) => ({ + sourceEnvName: item.sourceEnvName, + containerEnvName: item.containerEnvName, + present: item.present, + valuePrinted: false, + })), + dryRunDisposition: requirements.length === 0 + ? "not-required" + : requiredSecretsPresent + ? "ready-for-live-apply" + : "secret-source-blocked", + }; +} + +export function runtimeSecretContractFromEnvText( + envText: string, + requirements: RuntimeSecretRequirement[], + sourceOverrides: Partial> = {}, +): RuntimeSecretContract { + const source: RuntimeSecretSource = { + kind: "compose-env-file", + path: sourceOverrides.path ?? "", + exists: sourceOverrides.exists ?? true, + configSource: sourceOverrides.configSource ?? "config.providerGateway.upgrade.hostProjectRoot + config.providerGateway.upgrade.composeEnvFile", + workDir: sourceOverrides.workDir ?? "", + composeEnvFile: sourceOverrides.composeEnvFile ?? "", + composeService: sourceOverrides.composeService ?? null, + containerName: sourceOverrides.containerName ?? null, + valuesPrinted: false, + }; + return runtimeSecretContractFromPresence(source, requirements, runtimeSecretPresenceFromEnvText(envText, requirements)); +} + +export function runtimeSecretContractForComposeTarget( + config: UniDeskConfig, + target: ArtifactConsumerTarget, + requirements: RuntimeSecretRequirement[] | undefined, +): RuntimeSecretContract | undefined { + if (requirements === undefined) return undefined; + const source = runtimeSecretSourceForTarget(config, target); + const envText = source.exists ? readFileSync(source.path, "utf8") : ""; + return runtimeSecretContractFromPresence(source, requirements, runtimeSecretPresenceFromEnvText(envText, requirements)); } function composeArtifactSecretPreflight(envFile: string, requirements: RuntimeSecretRequirement[] | undefined): { ok: boolean; envFile: string; requirements: RuntimeSecretPresence[]; missing: RuntimeSecretPresence[]; valuesLogged: false } { @@ -1767,6 +1883,10 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec: const config = readConfig(); const runtime = composeArtifactRuntime(config, target); const secretPreflight = composeArtifactSecretPreflight(runtime.envFile, target.compose.requiredRuntimeSecrets); + const runtimeSecretsRaw = runtimeSecretContractForComposeTarget(config, target, target.compose.requiredRuntimeSecrets); + const runtimeSecrets = runtimeSecretsRaw === undefined + ? undefined + : runtimeSecretRecommendedActionForService(runtimeSecretsRaw, options.environment ?? "prod", spec.serviceId); if (!secretPreflight.ok) { return { ok: false, @@ -1778,6 +1898,11 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec: envFile: runtime.envFile, requirements: secretPreflight.requirements, missing: secretPreflight.missing, + secretSource: runtimeSecrets?.secretSource, + requiredSecretsPresent: runtimeSecrets?.requiredSecretsPresent ?? false, + missingSecretKeys: runtimeSecrets?.missingSecretKeys ?? secretPreflight.missing.map((item) => item.sourceEnvName), + recommendedAction: runtimeSecrets?.recommendedAction ?? "Restore the required runtime secrets in the canonical Compose env file, then rerun artifact deploy.", + runtimeSecrets, valuesLogged: false, recoveryHint: target.compose.authHealthGate?.recoveryHint ?? "Restore the required runtime secrets in the canonical Compose env file, then rerun artifact deploy.", }; @@ -1898,6 +2023,7 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec: serviceHealthCommit: target.compose.requireHealthCommit ? commit : "not-required", serviceHealthRequestedCommit: target.compose.requireHealthCommit ? commit : "not-required", requiredRuntimeSecrets: secretPreflight.requirements, + runtimeSecrets, authHealthGate: target.compose.authHealthGate === undefined ? "not-required" : { requiredFields: target.compose.authHealthGate.requiredFields, passed: true, @@ -2119,6 +2245,7 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti const livePolicy = environment === "prod" ? spec.prodLiveApply : "enabled"; const sourceImage = artifactImageRef(options, spec, commit); const registryEndpoint = `http://127.0.0.1:${options.port}`; + const config = readConfig(); const k3sDeployments = target.k3s === undefined ? [] : [ @@ -2180,6 +2307,8 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti }; if (spec.kind === "compose" || spec.kind === "d601-compose") { if (target.compose === undefined) throw new Error(`${spec.serviceId} missing compose artifact consumer config`); + const runtimeSecretsRaw = runtimeSecretContractForComposeTarget(config, target, target.compose.requiredRuntimeSecrets); + const runtimeSecrets = runtimeSecretsRaw === undefined ? undefined : runtimeSecretRecommendedActionForService(runtimeSecretsRaw, environment, spec.serviceId); return { ...common, target: { @@ -2227,16 +2356,7 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti `${spec.serviceId} live apply gates success on /health.auth fields: ${target.compose.authHealthGate.requiredFields.join(", ")}`, ]), ], - runtimeSecrets: target.compose.requiredRuntimeSecrets === undefined ? undefined : { - check: "live-apply-preflight", - valuesPrinted: false, - requirements: target.compose.requiredRuntimeSecrets.map((item) => ({ - sourceEnvName: item.sourceEnvName, - containerEnvName: item.containerEnvName, - presence: "not-read-during-dry-run", - })), - dryRunDisposition: "pending-live-check", - }, + runtimeSecrets, authHealthGate: target.compose.authHealthGate === undefined ? undefined : { path: "/health", requiredAuthFields: target.compose.authHealthGate.requiredFields, diff --git a/scripts/src/deploy.ts b/scripts/src/deploy.ts index 02b48c54..cabd5410 100644 --- a/scripts/src/deploy.ts +++ b/scripts/src/deploy.ts @@ -5,7 +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 { baiduNetdiskRuntimeSecretRequirements, runtimeSecretContractFromEnvText, type RuntimeSecretContract, runArtifactRegistryCommand } from "./artifact-registry"; import { startJob } from "./jobs"; import { coreInternalFetch } from "./microservices"; import { codeQueueSourceImportPreflight, codeQueueSourceSubdir } from "./code-queue-source-guard"; @@ -1216,6 +1216,27 @@ function directComposeEnvFile(service: UniDeskMicroserviceConfig): string { return targetIsMain(service) ? writeComposeEnvFallbackPath() : ""; } +function redactedSecretContractForService(config: UniDeskConfig | null, service: UniDeskMicroserviceConfig, environment: DeployEnvironment): RuntimeSecretContract | undefined { + if (config === null || service.id !== "baidu-netdisk" || !targetIsMain(service)) return undefined; + const composeEnvFile = config.providerGateway.upgrade.composeEnvFile; + const envFile = join(config.providerGateway.upgrade.hostProjectRoot, composeEnvFile); + const envText = existsSync(envFile) ? readFileSync(envFile, "utf8") : ""; + const contract = runtimeSecretContractFromEnvText(envText, baiduNetdiskRuntimeSecretRequirements, { + path: envFile, + exists: existsSync(envFile), + workDir: config.providerGateway.upgrade.hostProjectRoot, + composeEnvFile, + composeService: service.repository.composeService, + containerName: service.repository.containerName, + }); + return { + ...contract, + recommendedAction: contract.requiredSecretsPresent + ? "none" + : `Restore ${contract.missingSecretKeys.join(", ")} in the canonical Compose env file without printing values, then rerun deploy apply --env ${environment} --service ${service.id} --dry-run before any live apply.`, + }; +} + function directBuildContextOverride(service: UniDeskMicroserviceConfig): string { if (targetIsMain(service) && isUnideskRepo(service.repository.url)) return targetWorkDir(service); return ""; @@ -2946,6 +2967,7 @@ function environmentDryRunPlan( const dryRunBlockedReason = artifactConsumerDryRunBlockedServiceIds.get(service.id) ?? null; const planKind = serviceConfig === null ? "unsupported" : artifactConsumerPlanKind(serviceConfig, environment); const planTarget = serviceConfig === null ? null : artifactConsumerPlanTarget(serviceConfig, environment); + const runtimeSecrets = serviceConfig === null ? undefined : redactedSecretContractForService(config, serviceConfig, environment); const unsupportedReason = unsupported ? unsupportedEnvironmentPlanReason(service.id, environment) : null; const effectiveTarget = unsupported ? unsupportedPlanTarget(service.id, environment, unsupportedReason ?? "unsupported") @@ -2992,6 +3014,7 @@ function environmentDryRunPlan( noRuntimeSourceBuild: unsupported || planKind !== "d601-dev-target-side-build", dryRunOnly: unsupported || (environment === "prod" && prodArtifactLiveApplyBlockedServiceIds.has(service.id)) || dryRunBlockedReason !== null, blockedReason: unsupportedReason ?? dryRunBlockedReason ?? (environment === "prod" ? prodArtifactLiveApplyBlockedServiceIds.get(service.id) ?? null : null), + runtimeSecrets, }, target: effectiveTarget, validation: unsupported