From d5c6ead870488ae0ab36b8c38e00cba1888e50c6 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 27 May 2026 09:21:50 +0800 Subject: [PATCH] fix: keep deploy manifest human-authored --- deploy/README.md | 29 ++-- deploy/deploy.json | 26 +--- deploy/deploy.schema.json | 12 +- docs/artifact-catalog.md | 51 +++--- docs/dev-base-image-preflight.md | 8 +- docs/dev-gate-preflight.md | 4 +- docs/reference/g14-gitops-cicd.md | 8 +- scripts/deploy-desired-state-plan.test.mjs | 67 ++++---- scripts/dev-cloud-workbench-smoke.test.mjs | 4 +- scripts/g14-gitops-render.mjs | 145 +++++++++++------- scripts/refresh-artifact-catalog.mjs | 72 +-------- scripts/src/deploy-desired-state-plan.mjs | 92 ++++------- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 8 +- scripts/src/dev-gate-preflight.mjs | 44 ++++-- scripts/validate-artifact-catalog.mjs | 45 +++++- scripts/validate-contract.mjs | 30 ++-- 16 files changed, 308 insertions(+), 337 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index ed88fce9..302b6dc4 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -67,25 +67,20 @@ node scripts/deploy-desired-state-plan.mjs --target-tag --pretty node scripts/deploy-desired-state-plan.mjs --promotion-commit --check ``` -The desired-state plan reads only `deploy/deploy.json`, +The desired-state plan reads `deploy/deploy.json`, `deploy/artifact-catalog.dev.json`, `deploy/k8s/base/workloads.yaml`, and the -optional artifact report snapshot. It checks that commit IDs, service image -tags, workload images, and present env mirrors such as `HWLAB_COMMIT_ID`, -`HWLAB_IMAGE`, `HWLAB_IMAGE_TAG`, and `HWLAB_SKILLS_COMMIT_ID` converge. It -also blocks if `hwlab-cloud-api` loses the Code Agent provider Secret ref -`hwlab-code-agent-provider/openai-api-key`, the `OPENAI_API_KEY` env name, or -the DEV egress proxy base-url env. Without `--check`, `--target-ref` or -`--target-tag` reports a uniform older desired-state as a read-only promotion -plan. With `--check`, any explicit target is a hard current-main/commit-pinning -gate: stale deploy, catalog, workload image, or runtime mirror fields are -blocked before apply. +optional artifact report snapshot. `deploy/deploy.json` is the human-authored +runtime config truth source. Generated image identity lives in +`deploy/artifact-catalog.dev.json`; GitOps render overlays catalog image fields +and generated env mirrors such as `HWLAB_COMMIT_ID`, `HWLAB_IMAGE`, +`HWLAB_IMAGE_TAG`, and `HWLAB_SKILLS_COMMIT_ID` into runtime manifests. -`deploy/deploy.json` is the deploy truth, but artifact catalog and workload -refs are part of the same desired-state contract. Promotion updates must move -all three together by using the refresh command in blocked mode or -publish-report mode, then run `--target-ref origin/main --check` or the -promotion commit check. Report files are contextual evidence only and must not -override these desired-state files. +The plan blocks if generated artifact identity leaks back into `deploy.json`, +or if `hwlab-cloud-api` loses the Code Agent provider Secret ref +`hwlab-code-agent-provider/openai-api-key`, the `OPENAI_API_KEY` env name, or +the DEV egress proxy base-url env. Promotion updates must refresh only +`deploy/artifact-catalog.dev.json` from a publish report before render/check; +report files are contextual evidence only and must not override source truth. This is source/dry-run support only. It does not prove a registry image exists, does not build, pull, push, apply, restart, or touch PROD, and must not be used diff --git a/deploy/deploy.json b/deploy/deploy.json index 91ace1fe..591be5c1 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -1,7 +1,6 @@ { "manifestVersion": "v1", "environment": "dev", - "commitId": "af46386", "namespace": "hwlab-dev", "endpoint": "http://74.48.78.17:16667", "health": { @@ -181,7 +180,6 @@ "services": [ { "serviceId": "hwlab-cloud-api", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:af46386", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -190,9 +188,6 @@ "HWLAB_ENVIRONMENT": "dev", "HWLAB_PUBLIC_ENDPOINT": "http://74.48.78.17:16667", "HWLAB_CLOUD_API_PORT": "6667", - "HWLAB_COMMIT_ID": "af46386", - "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-api:af46386", - "HWLAB_IMAGE_TAG": "af46386", "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN": "unidesk-backend,provider-gateway,microservice-proxy", "HWLAB_CLOUD_DB_URL": "secretRef:hwlab-cloud-api-dev-db/database-url", "HWLAB_CLOUD_DB_SSL_MODE": "disable", @@ -230,7 +225,6 @@ }, { "serviceId": "hwlab-cloud-web", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:af46386", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -238,15 +232,11 @@ "env": { "HWLAB_ENVIRONMENT": "dev", "HWLAB_API_BASE_URL": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - "HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS": "1260000", - "HWLAB_COMMIT_ID": "af46386", - "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-web:af46386", - "HWLAB_IMAGE_TAG": "af46386" + "HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS": "1260000" } }, { "serviceId": "hwlab-agent-mgr", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:af46386", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -258,7 +248,6 @@ }, { "serviceId": "hwlab-agent-worker", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:af46386", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -270,7 +259,6 @@ }, { "serviceId": "hwlab-gateway", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:af46386", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -282,7 +270,6 @@ }, { "serviceId": "hwlab-gateway-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:af46386", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -302,7 +289,6 @@ }, { "serviceId": "hwlab-box-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:af46386", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -321,7 +307,6 @@ }, { "serviceId": "hwlab-patch-panel", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:af46386", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -342,7 +327,6 @@ }, { "serviceId": "hwlab-router", - "image": "127.0.0.1:5000/hwlab/hwlab-router:af46386", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -354,7 +338,6 @@ }, { "serviceId": "hwlab-tunnel-client", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:af46386", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -368,7 +351,6 @@ }, { "serviceId": "hwlab-edge-proxy", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:af46386", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -381,7 +363,6 @@ }, { "serviceId": "hwlab-cli", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:af46386", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -392,14 +373,11 @@ }, { "serviceId": "hwlab-agent-skills", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:af46386", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", "replicas": 1, - "env": { - "HWLAB_SKILLS_COMMIT_ID": "af46386" - } + "env": {} } ], "createdAt": "2026-05-21T00:00:00Z" diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index 58f80685..75da314f 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -3,7 +3,7 @@ "$id": "https://hwlab.pikastech.local/deploy/deploy.schema.json", "title": "HWLAB Deploy Manifest", "type": "object", - "required": ["manifestVersion", "environment", "commitId", "health", "publicEndpoints", "frp", "k3s", "profiles", "services"], + "required": ["manifestVersion", "environment", "health", "publicEndpoints", "frp", "k3s", "profiles", "services"], "properties": { "manifestVersion": { "type": "string", @@ -13,10 +13,6 @@ "type": "string", "enum": ["dev"] }, - "commitId": { - "type": "string", - "pattern": "^[a-f0-9]{7,40}$" - }, "namespace": { "type": "string", "minLength": 1, @@ -317,15 +313,11 @@ }, "service": { "type": "object", - "required": ["serviceId", "image", "namespace", "healthPath", "profile"], + "required": ["serviceId", "namespace", "healthPath", "profile"], "properties": { "serviceId": { "$ref": "#/$defs/serviceId" }, - "image": { - "type": "string", - "minLength": 1 - }, "namespace": { "type": "string", "minLength": 1 diff --git a/docs/artifact-catalog.md b/docs/artifact-catalog.md index eb06fedb..8dbeaec0 100644 --- a/docs/artifact-catalog.md +++ b/docs/artifact-catalog.md @@ -1,8 +1,9 @@ # HWLAB Artifact Catalog Contract -The artifact catalog is the DEV-only MVP contract for interpreting deployment -artifacts. It pins which HWLAB service image, commit, profile, namespace, and -health path a static deploy manifest is claiming for acceptance review. +The artifact catalog is the DEV-only MVP contract for interpreting generated +deployment artifacts. It pins which HWLAB service image, commit, profile, +namespace, and health path GitOps render must inject into runtime manifests. +`deploy/deploy.json` stays the human-authored runtime config truth source. This file is not automatically a CI publish record. It proves only the state it declares: @@ -34,10 +35,10 @@ metadata, then be copied into the catalog by the refresh command below. | File | Role | | --- | --- | -| `deploy/artifact-catalog.dev.json` | Machine-readable DEV artifact catalog sample and static source of truth for the skeleton. | -| `deploy/deploy.json` | DEV deploy manifest skeleton that the catalog explains. | +| `deploy/artifact-catalog.dev.json` | Machine-readable DEV artifact identity truth for generated image, digest, commit, and publish provenance fields. | +| `deploy/deploy.json` | Human-authored DEV runtime config such as service topology, env, health, profile, replicas, and endpoints. | | `scripts/g14-gitops-render.mjs` | Declares the Tekton primitive CI tasks and minimal guardrails that generate the G14 CI/CD control plane. | -| `scripts/deploy-desired-state-plan.mjs` | Read-only planner/checker for commit, image tag, workload image, and env mirror convergence across deploy desired-state files. | +| `scripts/deploy-desired-state-plan.mjs` | Read-only planner/checker for catalog artifact identity, generated identity leakage into deploy config, and deploy/runtime config contracts. | | `scripts/refresh-artifact-catalog.mjs` | Refreshes source/artifact commit tags and records either blocked `not_published` state or proven publish digests. | | `scripts/validate-artifact-catalog.mjs` | Local static validator for the catalog and deploy manifest skeleton. | | `scripts/preflight-dev-base-image.mjs` | Local DEV builder base-image preflight for future artifact publish. | @@ -51,8 +52,8 @@ rules: | Field | Rule | | --- | --- | | `serviceId` | Must be one of the frozen HWLAB service IDs and must appear exactly once. | -| `commitId` | Must match the catalog and `deploy/deploy.json` commit ID. Short or full lowercase Git SHA is allowed. | -| `image` | Must match the DEV registry target `//:` and the matching `deploy/deploy.json` service image. | +| `commitId` | Must match the top-level catalog commit ID. Short or full lowercase Git SHA is allowed. | +| `image` | Must match the DEV registry target `//:`. The matching field must not be copied into `deploy/deploy.json`. | | `imageTag` | Must be the first seven characters of `commitId`. Mutable tags such as `latest`, `dev`, or `main` are forbidden. | | `profile` | Must be `dev`. | | `namespace` | Must be `hwlab-dev`. | @@ -64,8 +65,8 @@ rules: | `digest` | Must be `not_published` in the skeleton catalog; it may be a registry `sha256:<64 hex>` digest only after publish evidence exists. | The catalog `commitId` is the artifact source commit, not proof that a registry -artifact exists. It must match `deploy/deploy.json`, every service `commitId`, -and the image tag. If a later target ref changes artifact build inputs after the +artifact exists. It must match every service `commitId` and the image tag. If a +later target ref changes artifact build inputs after the publish source, the DEV gate preflight may reopen `artifact-source-commit` even when the base-image blocker is already closed. @@ -81,28 +82,26 @@ node scripts/deploy-desired-state-plan.mjs --target-tag --pretty node scripts/deploy-desired-state-plan.mjs --promotion-commit --check ``` -The planner treats `deploy/deploy.json`, -`deploy/artifact-catalog.dev.json`, and `deploy/k8s/base/workloads.yaml` as the -authoritative source desired-state. It reports top-level commit IDs, catalog -service `commitId`/`image`/`imageTag`, deploy service image tags, workload -images, and present env mirrors including `HWLAB_COMMIT_ID`, `HWLAB_IMAGE`, -`HWLAB_IMAGE_TAG`, and `HWLAB_SKILLS_COMMIT_ID`. +The planner treats `deploy/deploy.json` as human-authored runtime config, +`deploy/artifact-catalog.dev.json` as artifact identity truth, and +`deploy/k8s/base/workloads.yaml` as a runtime template. It reports catalog +service `commitId`/`image`/`imageTag`, blocks generated artifact identity that +leaks into `deploy.json`, and leaves workload image/env mirror values to GitOps +render because render overwrites them from the catalog. -`--check` exits non-zero when the source files are missing or invalid, the -desired-state has internal drift, an explicit target tag is invalid or mutable, -or any authoritative field does not match the requested target. A uniform older +`--check` exits non-zero when the source files are missing or invalid, generated +artifact identity leaks into `deploy.json`, an explicit target tag is invalid or +mutable, or catalog artifact identity does not match the requested target. A uniform older state under `--target-ref` is only allowed in read-only plan mode; with `--target-ref origin/main --check` it is a blocker because applying it would keep DEV pinned to stale artifacts. The optional report snapshot is contextual evidence only and is not an authoritative desired-state source. For PR review or CI/CD promotion handoff, use `--promotion-commit ---check`. That mode is stricter than a `--target-ref` plan: every top-level -commit, catalog service commit, deploy service image tag, workload image tag, -and present mirror env (`HWLAB_COMMIT_ID`, `HWLAB_IMAGE`, -`HWLAB_IMAGE_TAG`, `HWLAB_SKILLS_COMMIT_ID`, `HWLAB_IMAGE_DIGEST`) must align -with the promotion commit/tag. A stale but internally consistent manifest is a -blocker in promotion mode because applying it would roll DEV back. +--check`. That mode is stricter than a `--target-ref` plan: the catalog +top-level commit and each catalog service commit/image tag must align with the +promotion commit/tag. A stale but internally consistent catalog is a blocker in +promotion mode because applying it would roll DEV back. The planner does not prove registry existence, publish success, DEV apply, or M3 DEV-LIVE. Only real DEV observation of @@ -112,7 +111,7 @@ trace, audit, and evidence records can be called M3 DEV-LIVE. ## Refresh Workflow When the source commit changes and no publish report proves digests, refresh the -catalog and deploy manifests in blocked mode: +catalog in blocked mode: ```sh node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked diff --git a/docs/dev-base-image-preflight.md b/docs/dev-base-image-preflight.md index e471ba27..d06a1d49 100644 --- a/docs/dev-base-image-preflight.md +++ b/docs/dev-base-image-preflight.md @@ -10,9 +10,11 @@ PROD, read secrets, or use UniDesk runtime services as substitutes. ## Current Repository Constraints - No repository Dockerfile is present in the DEV skeleton. -- `deploy/deploy.json` and `deploy/artifact-catalog.dev.json` pin service image - names to the DEV registry target, by default - `127.0.0.1:5000/hwlab/:`. +- `deploy/artifact-catalog.dev.json` pins service image names to the DEV + registry target, by default + `127.0.0.1:5000/hwlab/:`; `deploy/deploy.json` + remains human-authored runtime config and must not carry generated image + identity. - The artifact catalog may be either `contract-skeleton` or `published`. A `published` catalog must be backed by `/tmp/hwlab-dev-gate/dev-artifacts.json` with real `sha256:<64 hex>` digests for every required service. diff --git a/docs/dev-gate-preflight.md b/docs/dev-gate-preflight.md index f78fbeba..50c52917 100644 --- a/docs/dev-gate-preflight.md +++ b/docs/dev-gate-preflight.md @@ -94,8 +94,8 @@ The preflight checks: - Catalog image manifests are visible without reading credentials. A failed manifest read is tracked as a #66 registry reachability dimension and must not be conflated with missing publish digests. -- Deploy images do not point at UniDesk, provider-gateway, or microservice-proxy - substitutes. +- Catalog images do not point at UniDesk, provider-gateway, or microservice-proxy + substitutes; `deploy/deploy.json` must not contain generated image identity. ## Registry Capability Dimensions diff --git a/docs/reference/g14-gitops-cicd.md b/docs/reference/g14-gitops-cicd.md index bd68e383..fae3edfc 100644 --- a/docs/reference/g14-gitops-cicd.md +++ b/docs/reference/g14-gitops-cicd.md @@ -66,9 +66,9 @@ HWLAB 是 monorepo,G14 CI/CD 加速必须按组件输入判断构建和滚动 - 服务清单兼容顺序固定为:显式 `--services`、`deploy.services[]`、`deploy.k3s.serviceMappings[]`、`internal/protocol.SERVICE_IDS`。因此旧 `deploy/deploy.json` 形态和当前完整 `deploy.services[]` 形态都必须能被 planner 识别。 - 组件边界固定由 `scripts/src/g14-ci-plan-lib.mjs` 的内建 service-path model 定义;如需新增或调整 `componentPaths`、`sharedPaths`、`runtimeDeps` 或 `buildSystemPaths`,直接修改 planner 库和对应测试,不再额外维护 repo-local commands/forbidden skeleton。 - `scripts/g14-artifact-publish.mjs` 默认启用组件级 lazy build:先运行 planner,再只构建/推送 `affectedServices`,`reusedServices` 必须从 `deploy/artifact-catalog.dev.json` 复用已有 sha256 digest;如果 catalog 没有可验证 digest,必须结构化阻塞并修复 catalog 或显式扩大 changed service 范围,不能退回 Docker 或 legacy full-build 路线。 -- `scripts/g14-artifact-publish.mjs` 的 publish report 必须携带 planner 的 per-service 元数据;`scripts/refresh-artifact-catalog.mjs` 把这些字段复制进 `deploy/artifact-catalog.dev.json` 和 `deploy/deploy.json` 的对应 service。旧字段 `commitId`、`image`、`imageTag`、`digest`、`publishState` 语义不变,旧检查脚本不得因为新增字段失败。 -- `scripts/g14-gitops-render.mjs` 只支持混合 desired state:workload 的 container image、`HWLAB_IMAGE`、`HWLAB_IMAGE_TAG` 和 pod template `source-commit` 来自 `deploy.services[]` 的 per-service image/env;全局 GitOps metadata 仍记录本次 source commit。`--legacy-source-images` 与 `HWLAB_G14_USE_DEPLOY_IMAGES=0` 已废弃,不得把所有 workload image 回退渲染为同一个 source commit tag。 -- G14 Tekton promotion 在推送 `G14-gitops` 前,必须用 publish report 刷新 workspace 内的 `deploy/deploy.json`、`deploy/artifact-catalog.dev.json` 和 `deploy/k8s/base/workloads.yaml`,并把该 per-service artifact truth 以 `chore: promote G14 GitOps source ` 提交回 `G14` source branch;poller 必须跳过这类生成提交,避免自触发循环。这样下一轮 planner 才能从最新 catalog 复用上一轮已构建服务,避免把未变服务回滚到旧 digest。 +- `scripts/g14-artifact-publish.mjs` 的 publish report 必须携带 planner 的 per-service 元数据;`scripts/refresh-artifact-catalog.mjs` 只把生成的 `commitId`、`image`、`imageTag`、`digest`、`publishState` 和 component provenance 字段复制进 `deploy/artifact-catalog.dev.json`。`deploy/deploy.json` 是人写的 runtime config 真相源,不得被 promotion 回写镜像身份字段。 +- `scripts/g14-gitops-render.mjs` 只支持混合 desired state:workload 的 container image、`HWLAB_IMAGE`、`HWLAB_IMAGE_TAG` 和 pod template `source-commit` 来自 `deploy/artifact-catalog.dev.json` 的 per-service artifact identity;普通 env、replica、healthPath、profile 等配置来自 `deploy/deploy.json`。全局 GitOps metadata 仍记录本次 source commit。`--legacy-source-images` 与 `HWLAB_G14_USE_DEPLOY_IMAGES=0` 已废弃,不得把所有 workload image 回退渲染为同一个 source commit tag。 +- G14 Tekton promotion 在推送 `G14-gitops` 前,必须用 publish report 刷新 workspace 内的 `deploy/artifact-catalog.dev.json`,并且只在 catalog 变化时以 `chore: promote G14 GitOps source ` 提交回 `G14` source branch;poller 必须跳过这类生成提交,避免自触发循环。promotion 不得自动修改 `deploy/deploy.json` 或 `deploy/k8s/base/workloads.yaml`,这样人写配置不会和 CI 生成身份反复冲突。 - Tekton 并发化只能以 planner 输出作为输入;每个 service 都有独立 TaskRun,changed service 启动 BuildKit,unchanged service 只写 reuse result 并复用 catalog digest,且不能改 pod template,避免无意义 rollout。没有完整 per-service desired state 证据时,必须修复 planner/catalog 证据,不能使用 `--full-build`、`--legacy-source-images`、DIND 或 Docker fallback 回退。 ## 加速判定与当前瓶颈 @@ -78,7 +78,7 @@ G14 CI/CD 加速的第一判定标准不是 PipelineRun 总耗时单点变短, - 触发延迟:`hwlab-g14-branch-poller` 固定每 1 分钟轮询 `G14`。相对 5 分钟轮询,平均触发等待应从约 2.5 分钟降到约 0.5 分钟,最坏等待从 5 分钟降到 1 分钟。发现 `kubectl -n hwlab-ci get cronjob hwlab-g14-branch-poller -o jsonpath='{.spec.schedule}'` 不是 `* * * * *` 时,应先修 poller/reconciler,而不是手工长期创建 PipelineRun。 - 组件懒构建:docs-only、GitOps-only、poller/reconciler manifest-only 等不影响服务输入的变更,planner 应输出 `affectedServices=[]`、`buildSkippedCount=<全部服务数>`,各 service TaskRun 应写出 `status=reused` 和 `build-backend=reused-catalog`。这类变更不应重新构建任何服务镜像,也不应把 workload image 改成当前 source commit tag。 - 并发 fan-out:per-service TaskRun 应由 Tekton/k8s scheduler 同时调度,多个 service 的 build/reuse 窗口应接近“最慢单个服务任务耗时”,而不是所有服务串行相加。reuse-only 场景的 fan-out 窗口当前基线约为十几秒;真实组件构建场景仍需按 changed service 单独记录 BuildKit 耗时和 cache hit 情况。 -- CD rollout:unchanged service 必须复用 `deploy.services[]` 的 image/digest/env,pod template 不应因全局 source commit 改变而无意义滚动。手写 manifest(例如 `deepseek-proxy`、device-agent 类辅助 workload)只要复用某个已发布服务镜像,也必须走同一个 deploy catalog image 选择逻辑,不能直接用当前 source commit tag。 +- CD rollout:unchanged service 必须复用 `deploy/artifact-catalog.dev.json` 的 image/digest,pod template 不应因全局 source commit 改变而无意义滚动。手写 manifest(例如 `deepseek-proxy`、device-agent 类辅助 workload)只要复用某个已发布服务镜像,也必须走同一个 catalog image 选择逻辑,不能直接用当前 source commit tag。 - 运行态验证:GitOps promote 成功只说明 `G14-gitops` 分支更新;最终通过必须看 Argo Application revision、sync 状态、目标 namespace Deployment/StatefulSet ready、公网 health。Argo 还停在旧 revision 时,优先做 `argocd.argoproj.io/refresh=hard` 刷新;不要把已经推送的 `G14-gitops` 分支误判成运行面已滚动。 - Argo health 判定:`spec.suspend=true` 的模板型 Job 会让 Application 总 health 显示 `Suspended`。`hwlab-agent-worker-template`、`hwlab-cli-template` 这类只作为手动启动模板的 Job 必须带 `argocd.argoproj.io/ignore-healthcheck: "true"`,避免 DEV 实际 workload 全部 ready 时仍被误判为 Suspended;真正发布验收仍以长驻 workload ready、公网 health 和失败 Pod 清单为准。 diff --git a/scripts/deploy-desired-state-plan.test.mjs b/scripts/deploy-desired-state-plan.test.mjs index 6b6c537b..d3ae0ee9 100644 --- a/scripts/deploy-desired-state-plan.test.mjs +++ b/scripts/deploy-desired-state-plan.test.mjs @@ -28,7 +28,7 @@ async function makeFixture({ deployEnvCommitId = commitId, deployEnvImageTag = commitId, deployEnvImage = null, - deployEnvMirrors = true, + deployEnvMirrors = false, deploySkillsCommitId = null, deployCodeAgentProviderEnv = true, deployDbEnv = true, @@ -123,25 +123,25 @@ async function makeFixture({ const deploy = { manifestVersion: "v1", environment: "dev", - commitId, services: [ { serviceId, - image, namespace: "hwlab-dev", healthPath: "/health/live", profile: "dev", replicas: 1, - env: deployEnvMirrors - ? { + env: { + ...(deployEnvMirrors + ? { HWLAB_COMMIT_ID: deployEnvCommitId, HWLAB_IMAGE: deployEnvImage ?? image, HWLAB_IMAGE_TAG: deployEnvImageTag, - ...(deploySkillsCommitId ? { HWLAB_SKILLS_COMMIT_ID: deploySkillsCommitId } : {}), - ...dbEnv, - ...providerEnv + ...(deploySkillsCommitId ? { HWLAB_SKILLS_COMMIT_ID: deploySkillsCommitId } : {}) } - : {} + : {}), + ...dbEnv, + ...providerEnv + } } ] }; @@ -256,19 +256,16 @@ test("passes internally consistent desired-state", async () => { const plan = await buildDesiredStatePlan({ repoRoot }); assert.equal(plan.status, "pass"); assert.equal(plan.summary.desiredCommitId, "abc1234"); - assert.equal(plan.summary.presentMirrorCount, 6); + assert.equal(plan.summary.presentMirrorCount, 3); assert.deepEqual(plan.diagnostics, []); }); test("passes mixed desired-state when unchanged services reuse service-level artifact tags", async () => { const repoRoot = await makeFixture({ commitId: "803ba22", - deployImageTag: "6a84959", catalogCommitId: "803ba22", catalogServiceCommitId: "6a84959", catalogImageTag: "6a84959", - deployEnvCommitId: "6a84959", - deployEnvImageTag: "6a84959", workloadTag: "6a84959", workloadEnvCommitId: "6a84959", workloadEnvImageTag: "6a84959" @@ -278,16 +275,16 @@ test("passes mixed desired-state when unchanged services reuse service-level art assert.equal(plan.status, "pass"); assert.equal(plan.summary.desiredCommitId, "803ba22"); - assert.equal(plan.services[0].deploy.imageTag, "6a84959"); + assert.equal(plan.services[0].deploy.artifactIdentity, "human-authored-config"); assert.equal(plan.services[0].catalog.commitId, "6a84959"); assert.deepEqual(plan.diagnostics, []); }); -test("passes when cloud-web owns runtime identity mirrors", async () => { - const repoRoot = await makeFixture({ serviceId: "hwlab-cloud-web" }); +test("passes when cloud-web relies on render-generated runtime identity mirrors", async () => { + const repoRoot = await makeFixture({ serviceId: "hwlab-cloud-web", workloadEnvMirrors: false }); const plan = await buildDesiredStatePlan({ repoRoot }); assert.equal(plan.status, "pass"); - assert.equal(plan.summary.presentMirrorCount, 6); + assert.equal(plan.summary.presentMirrorCount, 0); assert.deepEqual(plan.diagnostics, []); }); @@ -362,21 +359,21 @@ test("blocks when cloud-api DB SSL mode drifts back to require", async () => { assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "cloud_api_db_contract_mismatch")); }); -test("blocks when cloud-web runtime identity mirrors are missing", async () => { +test("blocks when generated runtime identity leaks into deploy.json", async () => { const repoRoot = await makeFixture({ serviceId: "hwlab-cloud-web", - deployEnvMirrors: false, + deployEnvMirrors: true, workloadEnvMirrors: false }); const plan = await buildDesiredStatePlan({ repoRoot }); assert.equal(plan.status, "blocked"); - assert.equal(plan.summary.blockers, 6); + assert.equal(plan.summary.blockers, 3); assert.ok(plan.diagnostics.some((diagnostic) => - diagnostic.code === "missing_mirror" && + diagnostic.code === "generated_artifact_identity" && diagnostic.path === "deploy/deploy.json.services.hwlab-cloud-web.env.HWLAB_COMMIT_ID" )); assert.ok(plan.diagnostics.some((diagnostic) => - diagnostic.code === "missing_mirror" && + diagnostic.code === "generated_artifact_identity" && diagnostic.path.endsWith(".env.HWLAB_IMAGE_TAG") )); }); @@ -410,7 +407,8 @@ test("target tag review is read-only promotion_pending when current state is uni assert.equal(plan.status, "planned"); assert.equal(plan.target.convergence.state, "promotion_pending"); assert.equal(plan.summary.blockers, 0); - assert.match(plan.services[0].promotion.deployImage, /:def5678$/u); + assert.equal(plan.services[0].promotion.deployImage, null); + assert.match(plan.services[0].promotion.catalogImage, /:def5678$/u); }); test("target check blocks when current desired-state is uniformly older", async () => { @@ -421,7 +419,7 @@ test("target check blocks when current desired-state is uniformly older", async assert.equal(plan.target.convergenceRequirement, "target-check"); assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "target_desired_state_mismatch" && - diagnostic.message.includes("stale desired-state is not pinned") + diagnostic.message.includes("stale artifact catalog is not pinned") )); }); @@ -490,31 +488,28 @@ test("promotion commit check blocks an explicit non-matching target tag", async assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "promotion_tag_mismatch")); }); -test("blocks on mirror drift", async () => { +test("ignores generated workload mirror drift because render owns runtime identity", async () => { const repoRoot = await makeFixture({ workloadEnvImageTag: "badcafe" }); const plan = await buildDesiredStatePlan({ repoRoot }); - assert.equal(plan.status, "blocked"); - assert.equal(plan.summary.blockers, 1); - assert.equal(plan.diagnostics[0].code, "mirror_mismatch"); - assert.match(plan.diagnostics[0].path, /HWLAB_IMAGE_TAG/u); + assert.equal(plan.status, "pass"); + assert.deepEqual(plan.diagnostics, []); }); -test("blocks on skills commit mirror drift", async () => { - const repoRoot = await makeFixture({ deploySkillsCommitId: "badcafe" }); +test("blocks on skills commit mirror leak in deploy.json", async () => { + const repoRoot = await makeFixture({ deployEnvMirrors: true, deploySkillsCommitId: "badcafe" }); const plan = await buildDesiredStatePlan({ repoRoot }); assert.equal(plan.status, "blocked"); assert.ok(plan.diagnostics.some((diagnostic) => - diagnostic.code === "mirror_mismatch" && + diagnostic.code === "generated_artifact_identity" && diagnostic.path.endsWith("HWLAB_SKILLS_COMMIT_ID") )); }); -test("blocks on workload image drift", async () => { +test("ignores workload image drift because catalog is artifact truth", async () => { const repoRoot = await makeFixture({ workloadTag: "badcafe" }); const plan = await buildDesiredStatePlan({ repoRoot }); - assert.equal(plan.status, "blocked"); - assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "image_tag_mismatch")); - assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "image_mismatch")); + assert.equal(plan.status, "pass"); + assert.deepEqual(plan.diagnostics, []); }); test("reports partial target drift when catalog top-level commit moves but services stay old", async () => { diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 46019b33..8a6abb2e 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -39,7 +39,7 @@ const sourceIdentity = Object.freeze({ const expectedRuntimeIdentity = Object.freeze({ status: "observed", serviceId: "hwlab-cloud-api", - source: "deploy/artifact-catalog.dev.json+deploy/deploy.json", + source: "deploy/artifact-catalog.dev.json+deploy/deploy.json:runtime-config", commitId: "7de6edd", imageTag: "7de6edd", image: "127.0.0.1:5000/hwlab/hwlab-cloud-api:7de6edd" @@ -307,7 +307,7 @@ test("live workbench identity uses deploy desired runtime identity instead of gi assert.equal(deployed.status, "pass"); assert.equal(deployed.expectedCommit, "7de6edd"); assert.equal(deployed.expectedReportCommitId, "ff4b3f928f76"); - assert.equal(deployed.expectedSource, "deploy/artifact-catalog.dev.json+deploy/deploy.json"); + assert.equal(deployed.expectedSource, "deploy/artifact-catalog.dev.json+deploy/deploy.json:runtime-config"); }); test("live web asset identity blocks stale deployed primary assets", () => { diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index 7962558c..b7b5d42d 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -211,7 +211,7 @@ function usage() { ` --web-endpoint URL default: ${defaultWebEndpoint}`, ` --prod-runtime-endpoint URL default: ${defaultProdRuntimeEndpoint}`, ` --prod-web-endpoint URL default: ${defaultProdWebEndpoint}`, - " --use-deploy-images default and only supported mode: render workload images/env from deploy/deploy.json per service", + " --use-deploy-images default and only supported mode: render workload images from deploy/artifact-catalog.dev.json per service", " --check verify generated files are current without writing", " --no-write plan and print generated file list without writing" ].join("\n"); @@ -295,6 +295,18 @@ function upsertEnv(envList, name, value) { } } +function upsertEnvEntry(envList, entry) { + if (!Array.isArray(envList) || !entry?.name) return; + const existing = envList.find((item) => item?.name === entry.name); + if (existing) { + delete existing.value; + delete existing.valueFrom; + Object.assign(existing, cloneJson(entry)); + } else { + envList.push(cloneJson(entry)); + } +} + function annotate(metadata, annotations) { metadata.annotations = { ...(metadata.annotations ?? {}), ...annotations }; } @@ -315,37 +327,34 @@ function imageTagFromReference(image) { return value.slice(colonIndex + 1).split("@", 1)[0] || null; } -function runtimeCommitForDeployService(deployService, source) { - return deployService?.env?.HWLAB_COMMIT_ID ?? deployService?.commitId ?? imageTagFromReference(deployService?.image) ?? source.full; +function catalogServiceById(catalog, serviceId) { + return (catalog?.services ?? []).find((service) => service?.serviceId === serviceId) ?? null; } -function runtimeImageTagForDeployService(deployService, source) { - return deployService?.env?.HWLAB_IMAGE_TAG ?? imageTagFromReference(deployService?.image) ?? source.short; -} - -function deployServiceById(deploy, serviceId) { - return (deploy?.services ?? []).find((service) => service?.serviceId === serviceId) ?? null; -} - -function runtimeImageForService({ deploy, serviceId, source, registryPrefix, useDeployImages = false }) { - const deployService = deployServiceById(deploy, serviceId); - return useDeployImages && deployService?.image - ? deployService.image +function runtimeArtifactForService({ catalog, serviceId, source, registryPrefix, useDeployImages = false }) { + const catalogService = catalogServiceById(catalog, serviceId); + const image = useDeployImages && catalogService?.image + ? catalogService.image : imageFor(registryPrefix, serviceId, source.short); -} - -function runtimeCommitForService({ deploy, serviceId, source, useDeployImages = false }) { - const deployService = deployServiceById(deploy, serviceId); - return useDeployImages - ? runtimeCommitForDeployService(deployService, source) + const imageTag = useDeployImages && catalogService?.imageTag + ? catalogService.imageTag + : imageTagFromReference(image) ?? source.short; + const commit = useDeployImages + ? catalogService?.sourceCommitId ?? catalogService?.commitId ?? imageTag ?? source.full : source.full; + return { image, imageTag, commit }; } -function runtimeImageTagForService({ deploy, serviceId, source, useDeployImages = false }) { - const deployService = deployServiceById(deploy, serviceId); - return useDeployImages - ? runtimeImageTagForDeployService(deployService, source) - : source.short; +function runtimeImageForService({ catalog, serviceId, source, registryPrefix, useDeployImages = false }) { + return runtimeArtifactForService({ catalog, serviceId, source, registryPrefix, useDeployImages }).image; +} + +function runtimeCommitForService({ catalog, serviceId, source, registryPrefix, useDeployImages = false }) { + return runtimeArtifactForService({ catalog, serviceId, source, registryPrefix, useDeployImages }).commit; +} + +function runtimeImageTagForService({ catalog, serviceId, source, registryPrefix, useDeployImages = false }) { + return runtimeArtifactForService({ catalog, serviceId, source, registryPrefix, useDeployImages }).imageTag; } function namespaceNameForProfile(profile) { @@ -395,7 +404,34 @@ function namespaceScopedHost(value, namespace) { return value.replace(/\.hwlab-dev\.svc\.cluster\.local/gu, `.${namespace}.svc.cluster.local`); } -function transformWorkloads({ workloads, deploy, source, registryPrefix, runtimeEndpoint, webEndpoint, profile = "dev", useDeployImages = false }) { +function deployEnvEntry(name, value, namespace) { + if (typeof value === "string" && value.startsWith("secretRef:")) { + const ref = value.slice("secretRef:".length); + const slashIndex = ref.indexOf("/"); + if (slashIndex > 0 && slashIndex < ref.length - 1) { + return { + name, + valueFrom: { + secretKeyRef: { + name: ref.slice(0, slashIndex), + key: ref.slice(slashIndex + 1), + optional: true + } + } + }; + } + } + return { name, value: namespaceScopedHost(value, namespace) }; +} + +function applyDeployEnv(envList, deployService, namespace) { + if (!deployService?.env || typeof deployService.env !== "object" || Array.isArray(deployService.env)) return; + for (const [name, value] of Object.entries(deployService.env)) { + upsertEnvEntry(envList, deployEnvEntry(name, value, namespace)); + } +} + +function transformWorkloads({ workloads, deploy, catalog, source, registryPrefix, runtimeEndpoint, webEndpoint, profile = "dev", useDeployImages = false }) { const result = cloneJson(workloads); const deployServices = new Map((deploy.services ?? []).map((service) => [service.serviceId, service])); const namespace = namespaceNameForProfile(profile); @@ -434,10 +470,8 @@ function transformWorkloads({ workloads, deploy, source, registryPrefix, runtime const podTemplate = item?.spec?.template; if (!podTemplate?.spec?.containers) continue; const templateServiceId = serviceIdForWorkload(item, podTemplate.spec.containers[0]); - const templateDeployService = deployServices.get(templateServiceId); - const templateSourceCommit = useDeployImages - ? runtimeCommitForDeployService(templateDeployService, source) - : source.full; + const templateArtifact = runtimeArtifactForService({ catalog, serviceId: templateServiceId, source, registryPrefix, useDeployImages }); + const templateSourceCommit = templateArtifact.commit; label(podTemplate.metadata ??= {}, { ...stableRuntimeLabels, "hwlab.pikastech.local/gitops-target": "g14", @@ -457,21 +491,24 @@ function transformWorkloads({ workloads, deploy, source, registryPrefix, runtime const serviceId = serviceIdForWorkload(item, container); const deployService = deployServices.get(serviceId); if (!deployService) continue; - const image = useDeployImages && deployService.image - ? deployService.image - : imageFor(registryPrefix, serviceId, source.short); - const runtimeCommit = useDeployImages ? runtimeCommitForDeployService(deployService, source) : source.full; - const runtimeImageTag = useDeployImages ? runtimeImageTagForDeployService(deployService, source) : source.short; + const artifact = runtimeArtifactForService({ catalog, serviceId, source, registryPrefix, useDeployImages }); + const image = artifact.image; + const runtimeCommit = artifact.commit; + const runtimeImageTag = artifact.imageTag; container.image = image; container.imagePullPolicy = "IfNotPresent"; container.env ??= []; for (const entry of container.env) { if (Object.hasOwn(entry, "value")) entry.value = namespaceScopedHost(entry.value, namespace); } + applyDeployEnv(container.env, deployService, namespace); upsertEnv(container.env, "HWLAB_ENVIRONMENT", profileLabel); upsertEnv(container.env, "HWLAB_COMMIT_ID", runtimeCommit); upsertEnv(container.env, "HWLAB_IMAGE", image); upsertEnv(container.env, "HWLAB_IMAGE_TAG", runtimeImageTag); + if (serviceId === "hwlab-agent-skills") { + upsertEnv(container.env, "HWLAB_SKILLS_COMMIT_ID", runtimeCommit); + } upsertEnv(container.env, "HWLAB_GITOPS_TARGET", "g14"); upsertEnv(container.env, "HWLAB_GITOPS_PROFILE", profileLabel); upsertEnv(container.env, "HWLAB_GITOPS_SOURCE_COMMIT", source.full); @@ -881,9 +918,9 @@ git config --global user.name "HWLAB G14 GitOps Bot" git config --global user.email "hwlab-g14-gitops-bot@users.noreply.github.com" if [ -s /workspace/source/dev-artifacts.json ]; then node scripts/refresh-artifact-catalog.mjs --target-ref "$(params.revision)" --publish-report /workspace/source/dev-artifacts.json - if ! git diff --quiet -- deploy/deploy.json deploy/artifact-catalog.dev.json deploy/k8s/base/workloads.yaml; then + if ! git diff --quiet -- deploy/artifact-catalog.dev.json; then short="$(printf '%.7s' "$(params.revision)")" - git add deploy/deploy.json deploy/artifact-catalog.dev.json deploy/k8s/base/workloads.yaml + git add deploy/artifact-catalog.dev.json git commit -m "chore: promote G14 GitOps source $short" git push origin "HEAD:$(params.source-branch)" fi @@ -1823,12 +1860,12 @@ remotePort = ${edgeRemotePort} }; } -function deepSeekProxyManifest({ profile = "dev", source, registryPrefix = defaultRegistryPrefix, deploy = null, useDeployImages = false } = {}) { +function deepSeekProxyManifest({ profile = "dev", source, registryPrefix = defaultRegistryPrefix, catalog = null, useDeployImages = false } = {}) { const namespace = namespaceNameForProfile(profile); const bridgeServiceId = "hwlab-cloud-api"; - const bridgeImage = runtimeImageForService({ deploy, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); - const bridgeCommit = runtimeCommitForService({ deploy, serviceId: bridgeServiceId, source, useDeployImages }); - const bridgeImageTag = runtimeImageTagForService({ deploy, serviceId: bridgeServiceId, source, useDeployImages }); + const bridgeImage = runtimeImageForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); + const bridgeCommit = runtimeCommitForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); + const bridgeImageTag = runtimeImageTagForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); const labels = { "app.kubernetes.io/name": "hwlab-deepseek-proxy", "app.kubernetes.io/part-of": "hwlab", @@ -2108,14 +2145,14 @@ function deviceAgent71FreqServerScript() { ].join("\n") + "\n"; } -function deviceAgent71FreqManifest({ profile = "dev", source, registryPrefix, deploy = null, useDeployImages = false }) { +function deviceAgent71FreqManifest({ profile = "dev", source, registryPrefix, catalog = null, useDeployImages = false }) { assert.equal(profile, "dev", "71-freq device-agent is dev-only"); const namespace = namespaceNameForProfile(profile); const name = "device-agent-71-freq"; const bridgeServiceId = "hwlab-cloud-api"; - const bridgeImage = runtimeImageForService({ deploy, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); - const bridgeCommit = runtimeCommitForService({ deploy, serviceId: bridgeServiceId, source, useDeployImages }); - const bridgeImageTag = runtimeImageTagForService({ deploy, serviceId: bridgeServiceId, source, useDeployImages }); + const bridgeImage = runtimeImageForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); + const bridgeCommit = runtimeCommitForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); + const bridgeImageTag = runtimeImageTagForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); const labels = { "app.kubernetes.io/name": name, "app.kubernetes.io/part-of": "hwlab", @@ -2213,11 +2250,11 @@ function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile == function readme({ source, args }) { return `# HWLAB G14 GitOps CI/CD -This directory is generated from built-in Tekton-native CI primitive declarations plus \`deploy/deploy.json\` and \`deploy/k8s/*\` by \`scripts/g14-gitops-render.mjs\`. +This directory is generated from built-in Tekton-native CI primitive declarations plus human-authored \`deploy/deploy.json\`, CI-authored \`deploy/artifact-catalog.dev.json\`, and \`deploy/k8s/*\` by \`scripts/g14-gitops-render.mjs\`. - Target: G14 k3s only. - CI: Tekton Pipeline \`hwlab-ci/hwlab-g14-ci-image-publish\`. -- Artifact contract: images are tagged by source git commit \`${source.short}\` and pushed to \`${args.registryPrefix}\`. +- Artifact contract: runtime images come from \`deploy/artifact-catalog.dev.json\`; \`deploy/deploy.json\` only carries human-authored config such as env and topology. - Branch split: source branch \`${args.sourceBranch}\` remains the watched code branch; generated desired state is promoted to \`${args.gitopsBranch}\`. - CD: Argo CD Applications \`argocd/hwlab-g14-dev\` and \`argocd/hwlab-g14-prod\` consume \`${args.gitopsBranch}:deploy/gitops/g14/runtime-dev\` and \`runtime-prod\`. - Public preview: FRP exposes G14 DEV web \`${args.webEndpoint}\` / edge \`${args.runtimeEndpoint}\` through \`hwlab-dev/hwlab-g14-frpc\`, and G14 PROD web \`${args.prodWebEndpoint}\` / edge \`${args.prodRuntimeEndpoint}\` through \`hwlab-prod/hwlab-g14-prod-frpc\`; D601 keeps \`:16666/:16667\`. @@ -2229,8 +2266,9 @@ This directory is generated from built-in Tekton-native CI primitive declaration } async function plannedFiles(args) { - const [deploy, namespaceTemplate, config, services, health, workloads] = await Promise.all([ + const [deploy, artifactCatalog, namespaceTemplate, config, services, health, workloads] = await Promise.all([ readJson("deploy/deploy.json"), + readJson("deploy/artifact-catalog.dev.json"), readJson("deploy/k8s/base/namespace.yaml"), readJson("deploy/k8s/base/code-agent-codex-config.yaml"), readJson("deploy/k8s/base/services.yaml"), @@ -2267,7 +2305,8 @@ async function plannedFiles(args) { }; if (args.useDeployImages) { sourceDescriptor.renderMode = { - imageSource: "deploy.services", + imageSource: "deploy/artifact-catalog.dev.json", + configSource: "deploy/deploy.json", mixedDesiredState: true }; } @@ -2298,9 +2337,9 @@ async function plannedFiles(args) { putJson(`${runtimePath}/code-agent-codex-config.yaml`, transformListNamespace(config, namespace, profileLabels, annotations)); putJson(`${runtimePath}/services.yaml`, transformListNamespace(services, namespace, profileLabels, annotations)); putJson(`${runtimePath}/health-contract.yaml`, transformHealthContract(health, namespace, profileLabels, annotations, endpoints.runtimeEndpoint, endpoints.webEndpoint, profile)); - putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, source, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, useDeployImages: args.useDeployImages })); - putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, registryPrefix: args.registryPrefix, deploy, useDeployImages: args.useDeployImages })); - if (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix, deploy, useDeployImages: args.useDeployImages })); + putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, catalog: artifactCatalog, source, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, useDeployImages: args.useDeployImages })); + putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages })); + if (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages })); putJson(`${runtimePath}/g14-frpc.yaml`, g14FrpcManifest({ profile })); } return { files, source }; diff --git a/scripts/refresh-artifact-catalog.mjs b/scripts/refresh-artifact-catalog.mjs index 7bfcf290..cc6a1b53 100644 --- a/scripts/refresh-artifact-catalog.mjs +++ b/scripts/refresh-artifact-catalog.mjs @@ -17,7 +17,6 @@ const execFileAsync = promisify(execFile); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const catalogPath = "deploy/artifact-catalog.dev.json"; const deployPath = "deploy/deploy.json"; -const workloadsPath = "deploy/k8s/base/workloads.yaml"; const defaultPublishReportPath = tempReportPath("dev-artifacts.json"); const defaultRegistryPrefix = process.env.HWLAB_DEV_REGISTRY_PREFIX || process.env.HWLAB_DEV_REGISTRY || "127.0.0.1:5000/hwlab"; const digestPattern = /^sha256:[a-f0-9]{64}$/; @@ -225,49 +224,7 @@ function publishRecordsFromReport(report, target) { return { records, requiredServiceIds, disabledServiceIds }; } -function updateEnvObject(env, service, target, publishRecord) { - if (!env || typeof env !== "object" || Array.isArray(env)) return; - const shortCommitId = shortCommitForRecord(publishRecord, target); - const revision = revisionForRecord(publishRecord, target); - if (Object.hasOwn(env, "HWLAB_COMMIT_ID")) env.HWLAB_COMMIT_ID = shortCommitId; - if (Object.hasOwn(env, "HWLAB_IMAGE")) env.HWLAB_IMAGE = service.image; - if (Object.hasOwn(env, "HWLAB_IMAGE_TAG")) env.HWLAB_IMAGE_TAG = service.imageTag; - if (Object.hasOwn(env, "HWLAB_REVISION")) env.HWLAB_REVISION = revision; - if (Object.hasOwn(env, "HWLAB_BUILD_CREATED_AT")) env.HWLAB_BUILD_CREATED_AT = publishRecord?.buildCreatedAt ?? ""; - if (Object.hasOwn(env, "HWLAB_BUILD_SOURCE")) env.HWLAB_BUILD_SOURCE = publishRecord?.buildSource ?? ""; - if (Object.hasOwn(env, "HWLAB_SKILLS_COMMIT_ID")) env.HWLAB_SKILLS_COMMIT_ID = shortCommitId; - if (Object.hasOwn(env, "HWLAB_IMAGE_DIGEST")) env.HWLAB_IMAGE_DIGEST = publishRecord?.digest ?? "not_published"; -} - -function serviceIdForWorkload(item, container) { - return ( - item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] || - item?.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/service-id"] || - container?.name - ); -} - -function updateEnvList(envList, service, target, publishRecord) { - if (!Array.isArray(envList)) return; - const shortCommitId = shortCommitForRecord(publishRecord, target); - const revision = revisionForRecord(publishRecord, target); - for (const entry of envList) { - if (entry.name === "HWLAB_COMMIT_ID") entry.value = shortCommitId; - if (entry.name === "HWLAB_IMAGE") entry.value = service.image; - if (entry.name === "HWLAB_IMAGE_TAG") entry.value = service.imageTag; - if (entry.name === "HWLAB_REVISION") entry.value = revision; - if (entry.name === "HWLAB_BUILD_CREATED_AT") entry.value = publishRecord?.buildCreatedAt ?? ""; - if (entry.name === "HWLAB_BUILD_SOURCE") entry.value = publishRecord?.buildSource ?? ""; - if (entry.name === "HWLAB_SKILLS_COMMIT_ID") entry.value = shortCommitId; - if (entry.name === "HWLAB_IMAGE_DIGEST") entry.value = publishRecord?.digest ?? "not_published"; - } -} - -function workloadContainers(item) { - return item?.spec?.template?.spec?.containers ?? []; -} - -function refreshDocuments({ deploy, catalog, workloads, target, publishRecords, serviceInventory, provenancePath, registryPrefix }) { +function refreshDocuments({ deploy, catalog, target, publishRecords, serviceInventory, provenancePath, registryPrefix }) { const published = Boolean(publishRecords); const records = publishRecords?.records ?? null; const requiredIds = new Set(publishRecords?.requiredServiceIds ?? serviceInventory.requiredServiceIds); @@ -276,7 +233,6 @@ function refreshDocuments({ deploy, catalog, workloads, target, publishRecords, const inventoryByService = new Map(serviceInventory.services.map((service) => [service.serviceId, service])); const refreshedServices = []; - deploy.commitId = target.shortCommitId; catalog.commitId = target.shortCommitId; catalog.artifactState = published ? "published" : "contract-skeleton"; catalog.serviceInventory = serviceInventory; @@ -303,9 +259,6 @@ function refreshDocuments({ deploy, catalog, workloads, target, publishRecords, const imageTag = publishRecord?.imageTag ?? target.shortCommitId; const serviceCommitId = shortCommitForRecord(publishRecord, target); - deployService.image = image; - updateEnvObject(deployService.env, { image, imageTag }, target, publishRecord); - catalogService.commitId = serviceCommitId; catalogService.image = image; catalogService.imageTag = imageTag; @@ -347,16 +300,6 @@ function refreshDocuments({ deploy, catalog, workloads, target, publishRecords, }); } - for (const item of workloads.items ?? []) { - for (const container of workloadContainers(item)) { - const serviceId = serviceIdForWorkload(item, container); - const service = catalogByService.get(serviceId); - if (!service) continue; - container.image = service.image; - updateEnvList(container.env, service, target, records?.get(serviceId) ?? null); - } - } - return refreshedServices; } @@ -374,10 +317,9 @@ async function main() { } const target = await resolveTarget(args.targetRef); - const [deploy, catalog, workloads, publishReport] = await Promise.all([ + const [deploy, catalog, publishReport] = await Promise.all([ readJson(deployPath), readJson(catalogPath), - readJson(workloadsPath), args.publishReportPath ? readJson(args.publishReportPath) : Promise.resolve(null) ]); assertDevOnlyDeployAndCatalog(deploy, catalog); @@ -389,7 +331,6 @@ async function main() { const services = refreshDocuments({ deploy, catalog, - workloads, target, publishRecords, serviceInventory, @@ -398,11 +339,7 @@ async function main() { }); if (args.write) { - await Promise.all([ - writeJson(deployPath, deploy), - writeJson(catalogPath, catalog), - writeJson(workloadsPath, workloads) - ]); + await writeJson(catalogPath, catalog); } console.log(JSON.stringify({ @@ -410,7 +347,8 @@ async function main() { targetRef: target.ref, sourceCommitId: target.commitId, artifactCommitId: target.shortCommitId, - wrote: args.write ? [deployPath, catalogPath, workloadsPath] : [], + wrote: args.write ? [catalogPath] : [], + deployTruthPolicy: "deploy.json is human-authored runtime config; artifact promotion must not rewrite it", ciPublished: Boolean(publishRecords), registryVerified: Boolean(publishRecords), publishedCount: publishRecords?.records.size ?? 0, diff --git a/scripts/src/deploy-desired-state-plan.mjs b/scripts/src/deploy-desired-state-plan.mjs index e3b26bf3..1358d346 100644 --- a/scripts/src/deploy-desired-state-plan.mjs +++ b/scripts/src/deploy-desired-state-plan.mjs @@ -26,13 +26,12 @@ const mirrorEnvNames = [ "HWLAB_SKILLS_COMMIT_ID", "HWLAB_IMAGE_DIGEST" ]; +const forbiddenDeployArtifactFields = new Set(["commitId", "image", "imageTag", "digest", "sourceCommitId", "repositoryDigest", "publishState"]); +const forbiddenDeployArtifactEnv = new Set(mirrorEnvNames.concat(["HWLAB_REVISION", "HWLAB_BUILD_CREATED_AT", "HWLAB_BUILD_SOURCE"])); const commitMirrorEnvNames = new Set(["HWLAB_COMMIT_ID", "HWLAB_SKILLS_COMMIT_ID"]); const imageMirrorEnvNames = new Set(["HWLAB_IMAGE"]); const tagMirrorEnvNames = new Set(["HWLAB_IMAGE_TAG"]); const digestMirrorEnvNames = new Set(["HWLAB_IMAGE_DIGEST"]); -const requiredMirrorEnvNamesByService = new Map([ - ["hwlab-cloud-web", ["HWLAB_COMMIT_ID", "HWLAB_IMAGE", "HWLAB_IMAGE_TAG"]] -]); const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/; const commitPattern = /^[a-f0-9]{7,40}$/; @@ -621,7 +620,7 @@ function buildTargetConvergence(ctx, target) { path: "desired-state", message: promotionCommitRequired ? "desired-state fields do not match the required promotion commit/tag" - : `stale desired-state is not pinned to ${targetLabel(target)}; explicit target checks require current-main/commit-pinned deploy, catalog, workload, and runtime mirror fields`, + : `stale artifact catalog is not pinned to ${targetLabel(target)}; explicit target checks require current-main/commit-pinned catalog identity`, expected: { commitId: target.comparisonCommitId, imageTag: target.comparisonTag @@ -691,7 +690,7 @@ function servicePromotion(service, target) { return { targetCommitId: target.comparisonCommitId ?? target.commitId, targetImageTag: targetTag, - deployImage: service.deploy?.image ? replaceImageTag(service.deploy.image, targetTag) : null, + deployImage: null, catalogImage: service.catalog?.image ? replaceImageTag(service.catalog.image, targetTag) : null, workloadImages: (service.workloads ?? []).map((workload) => ({ workload: workload.name, @@ -738,17 +737,9 @@ export async function buildDesiredStatePlan(options = {}) { const deployByService = new Map(deployServices.map((service) => [service.serviceId, service])); const catalogByService = new Map(catalogServices.map((service) => [service.serviceId, service])); const workloadByService = indexWorkloads(workloads); - const desiredCommitId = deploy.commitId; + const desiredCommitId = catalog.commitId; const desiredImageTag = shortCommit(desiredCommitId); - recordObservation(ctx, { - source: "deploy", - path: `${deployPath}.commitId`, - field: "commitId", - kind: "commit", - value: deploy.commitId, - expected: desiredCommitId - }); recordObservation(ctx, { source: "catalog", path: `${catalogPath}.commitId`, @@ -758,11 +749,11 @@ export async function buildDesiredStatePlan(options = {}) { expected: desiredCommitId }); - if (!commitPattern.test(String(deploy.commitId ?? ""))) { - addMismatch(ctx, "invalid_commit", `${deployPath}.commitId`, "deploy commitId must be a short or full lowercase Git SHA", "7-40 lowercase hex", deploy.commitId); + if (Object.hasOwn(deploy, "commitId")) { + addMismatch(ctx, "generated_artifact_identity", `${deployPath}.commitId`, "deploy.json is human-authored config; generated commit identity must stay in artifact catalog", "absent", deploy.commitId); } - if (!commitEquivalent(catalog.commitId, desiredCommitId)) { - addMismatch(ctx, "commit_mismatch", `${catalogPath}.commitId`, "artifact catalog commitId must match deploy commitId", desiredCommitId, catalog.commitId); + if (!commitPattern.test(String(catalog.commitId ?? ""))) { + addMismatch(ctx, "invalid_commit", `${catalogPath}.commitId`, "artifact catalog commitId must be a short or full lowercase Git SHA", "7-40 lowercase hex", catalog.commitId); } const serviceIds = serviceIdsFrom( @@ -796,44 +787,31 @@ export async function buildDesiredStatePlan(options = {}) { addMismatch(ctx, "missing_workload", `${workloadsPath}.${serviceId}`, `${serviceId} missing from k8s workloads`, "workload container", null, { serviceId }); } - const deployImage = parseImageRef(deployService?.image); const catalogImage = parseImageRef(catalogService?.image); - const serviceImageTag = catalogService?.imageTag ?? deployImage.tag ?? catalogImage.tag ?? desiredImageTag; + const serviceImageTag = catalogService?.imageTag ?? catalogImage.tag ?? desiredImageTag; const serviceCommitId = catalogService?.commitId ?? serviceImageTag; - const expectedService = { - image: deployService?.image ?? catalogService?.image ?? null, - imageTag: serviceImageTag, - commitId: serviceCommitId, - digest: catalogService?.digest ?? "not_published" - }; - if (deployService) { const deployPathBase = `${deployPath}.services.${serviceId}`; - recordObservation(ctx, { - source: "deploy", - serviceId, - path: `${deployPathBase}.image`, - field: "image", - kind: "image", - value: deployService.image, - expected: catalogService?.image ?? deployService.image - }); - if (!deployImage.valid) { - addMismatch(ctx, "invalid_image", `${deployPathBase}.image`, deployImage.reason, "tagged image ref", deployService.image, { serviceId }); - } else if (deployImage.tag !== serviceImageTag) { - addMismatch(ctx, "image_tag_mismatch", `${deployPathBase}.image`, `${serviceId} deploy image tag must match service artifact tag`, serviceImageTag, deployImage.tag, { serviceId }); + for (const field of Object.keys(deployService)) { + if (forbiddenDeployArtifactFields.has(field)) { + addMismatch(ctx, "generated_artifact_identity", `${deployPathBase}.${field}`, `${serviceId} deploy service field is generated artifact identity and must stay in artifact catalog`, "absent", deployService[field], { serviceId }); + } + } + for (const envName of Object.keys(deployService.env ?? {})) { + if (forbiddenDeployArtifactEnv.has(envName)) { + addMismatch(ctx, "generated_artifact_identity", `${deployPathBase}.env.${envName}`, `${serviceId} deploy env is generated artifact identity and must be rendered from artifact catalog`, "absent", deployService.env[envName], { serviceId }); + } } service.deploy = { - image: deployService.image, - imageTag: deployImage.tag, + artifactIdentity: "human-authored-config", envMirrors: inspectEnvMirrors(ctx, { source: "deploy", basePath: `${deployPathBase}.env`, serviceId, env: deployService.env ?? {}, - service: expectedService, - desiredCommitId, - requiredMirrorNames: requiredMirrorEnvNamesByService.get(serviceId) ?? [] + service: null, + desiredCommitId: null, + requiredMirrorNames: [] }) }; } @@ -856,7 +834,7 @@ export async function buildDesiredStatePlan(options = {}) { field: "image", kind: "image", value: catalogService.image, - expected: deployService?.image ?? catalogService.image + expected: catalogService.image }); recordObservation(ctx, { source: "catalog", @@ -881,9 +859,6 @@ export async function buildDesiredStatePlan(options = {}) { if (catalogImage.valid && catalogImage.tag !== catalogService.imageTag) { addMismatch(ctx, "image_tag_mismatch", `${catalogPathBase}.image`, `${serviceId} catalog image tag must match catalog imageTag`, catalogService.imageTag, catalogImage.tag, { serviceId }); } - if (deployService?.image && catalogService.image !== deployService.image) { - addMismatch(ctx, "image_mismatch", `${catalogPathBase}.image`, `${serviceId} catalog image must match deploy image`, deployService.image, catalogService.image, { serviceId }); - } service.catalog = { commitId: catalogService.commitId, image: catalogService.image, @@ -903,15 +878,10 @@ export async function buildDesiredStatePlan(options = {}) { field: "image", kind: "image", value: workload.image, - expected: deployService?.image ?? catalogService?.image ?? null + expected: catalogService?.image ?? null }); if (!workloadImage.valid) { addMismatch(ctx, "invalid_image", `${workload.path}.image`, workloadImage.reason, "tagged image ref", workload.image, { serviceId }); - } else if (workloadImage.tag !== serviceImageTag) { - addMismatch(ctx, "image_tag_mismatch", `${workload.path}.image`, `${serviceId} workload image tag must match service artifact tag`, serviceImageTag, workloadImage.tag, { serviceId }); - } - if (deployService?.image && workload.image !== deployService.image) { - addMismatch(ctx, "image_mismatch", `${workload.path}.image`, `${serviceId} workload image must match deploy image`, deployService.image, workload.image, { serviceId }); } service.workloads.push({ kind: workload.kind, @@ -925,9 +895,9 @@ export async function buildDesiredStatePlan(options = {}) { basePath: `${workload.path}.env`, serviceId, env: workload.env, - service: expectedService, - desiredCommitId, - requiredMirrorNames: requiredMirrorEnvNamesByService.get(serviceId) ?? [] + service: null, + desiredCommitId: null, + requiredMirrorNames: [] }) }); } @@ -976,7 +946,7 @@ export async function buildDesiredStatePlan(options = {}) { prod: false, devLiveClaim: false }, - checkSemantics: "--check exits non-zero for missing/invalid desired-state sources, internal commit/image mirror drift, invalid target tags, partial target drift, or any mismatch with an explicit --target-ref, --target-tag, or --promotion-commit. When the checked target ref is the current checked-out merge commit, the comparable current-main target is its accepted first parent because a commit cannot embed its own content hash. Without --check, a uniform older desired-state under --target-ref/--target-tag is a read-only promotion plan.", + checkSemantics: "--check exits non-zero for missing/invalid desired-state sources, generated artifact identity leaking into deploy.json, invalid target tags, partial target drift, or catalog identity mismatch with an explicit --target-ref, --target-tag, or --promotion-commit. When the checked target ref is the current checked-out merge commit, the comparable current-main target is its accepted first parent because a commit cannot embed its own content hash. Without --check, a uniform older catalog under --target-ref/--target-tag is a read-only promotion plan.", summary: { desiredCommitId, desiredImageTag, @@ -998,8 +968,10 @@ export async function buildDesiredStatePlan(options = {}) { promotionBoundary: { writes: [], authoritativeDesiredState: [deployPath, catalogPath, workloadsPath], + humanAuthoredTruth: [deployPath, workloadsPath], + artifactIdentityTruth: [catalogPath], nonAuthoritativeEvidence: [artifactReportPath], - note: "This planner reviews source desired-state only. It does not prove image existence, registry reachability, a real DEV apply, or M3 DEV-LIVE hardware-loop evidence." + note: "This planner reviews source desired-state only. deploy.json is human-authored config; artifact-catalog carries generated image identity. It does not prove image existence, registry reachability, a real DEV apply, or M3 DEV-LIVE hardware-loop evidence." }, cloudApiDb: cloudApiDb ?? { status: "blocked", diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index b2c465ed..61394b33 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -1887,12 +1887,12 @@ function observeExpectedRuntimeIdentity(serviceId) { const catalogService = Array.isArray(catalog?.services) ? catalog.services.find((service) => service?.serviceId === serviceId) : null; - const image = sanitizeRuntimeString(catalogService?.image ?? deployService?.image); - const imageTag = sanitizeRuntimeString(catalogService?.imageTag ?? deployService?.env?.HWLAB_IMAGE_TAG ?? imageTagFromImage(image)); - const commitId = sanitizeRevision(catalogService?.commitId ?? deployService?.env?.HWLAB_COMMIT_ID ?? deploy?.commitId ?? catalog?.commitId ?? imageTag); + const image = sanitizeRuntimeString(catalogService?.image); + const imageTag = sanitizeRuntimeString(catalogService?.imageTag ?? imageTagFromImage(image)); + const commitId = sanitizeRevision(catalogService?.commitId ?? catalog?.commitId ?? imageTag); const sources = []; if (catalogService) sources.push("deploy/artifact-catalog.dev.json"); - if (deployService || deploy?.commitId) sources.push("deploy/deploy.json"); + if (deployService) sources.push("deploy/deploy.json:runtime-config"); const observed = commitId !== "unknown" || imageTag !== "unknown"; return { status: observed ? "observed" : "not_observed", diff --git a/scripts/src/dev-gate-preflight.mjs b/scripts/src/dev-gate-preflight.mjs index 69d7bde9..5fbc9aaf 100644 --- a/scripts/src/dev-gate-preflight.mjs +++ b/scripts/src/dev-gate-preflight.mjs @@ -47,6 +47,17 @@ const blockerTypes = new Set([ "safety_blocker" ]); const digestPattern = /^sha256:[a-f0-9]{64}$/; +const forbiddenDeployArtifactFields = new Set(["commitId", "image", "imageTag", "digest", "sourceCommitId", "repositoryDigest", "publishState"]); +const forbiddenDeployArtifactEnv = new Set([ + "HWLAB_COMMIT_ID", + "HWLAB_IMAGE", + "HWLAB_IMAGE_TAG", + "HWLAB_IMAGE_DIGEST", + "HWLAB_REVISION", + "HWLAB_BUILD_CREATED_AT", + "HWLAB_BUILD_SOURCE", + "HWLAB_SKILLS_COMMIT_ID" +]); const artifactBuildInputPrefixes = [ "cmd/", "internal/", @@ -394,7 +405,6 @@ async function artifactIdentityFor({ deploy, catalog, artifactReport, targetComm const requiredServices = requiredCatalogServices(catalog); const disabledServices = disabledCatalogServices(catalog); const catalogMatchesSource = matchesTargetCommit(catalog.commitId, artifactSourceCommit, artifactSourceShortCommit); - const deployMatchesSource = matchesTargetCommit(deploy.commitId, artifactSourceCommit, artifactSourceShortCommit); const servicesMatchSource = serviceCommitIds.length === 1 && matchesTargetCommit(serviceCommitIds[0], artifactSourceCommit, artifactSourceShortCommit); const allRequiredDigestsPublished = requiredServices.length > 0 && @@ -421,9 +431,10 @@ async function artifactIdentityFor({ deploy, catalog, artifactReport, targetComm targetCoverage, deployManifest: { path: "deploy/deploy.json", - commitId: deploy.commitId, - matchesSource: deployMatchesSource, - matchesTarget: matchesTargetCommit(deploy.commitId, targetCommit, targetShortCommit) + identitySource: "human-authored-config", + artifactIdentity: false, + matchesSource: true, + matchesTarget: true }, artifactCatalog: { path: "deploy/artifact-catalog.dev.json", @@ -451,7 +462,7 @@ async function artifactIdentityFor({ deploy, catalog, artifactReport, targetComm notPublishedReason: service.notPublishedReason ?? null })), serviceCommitIds, - matchesSource: targetCoverage.covered && deployMatchesSource && catalogMatchesSource && servicesMatchSource, + matchesSource: targetCoverage.covered && catalogMatchesSource && servicesMatchSource, publishVerified, refreshCommands: { blocked: `node scripts/refresh-artifact-catalog.mjs --target-ref ${targetRef} --blocked`, @@ -467,6 +478,17 @@ function assertStaticContract(deploy, catalog) { assert.equal(deploy.profiles.dev.enabled, true, "dev profile must be enabled"); assert.equal(deploy.profiles.prod.enabled, false, "prod profile must stay disabled"); assert.deepEqual(deploy.services.map((service) => service.serviceId), SERVICE_IDS); + for (const field of Object.keys(deploy)) { + assert.equal(forbiddenDeployArtifactFields.has(field), false, `deploy ${field} must stay in artifact catalog`); + } + for (const service of deploy.services) { + for (const field of Object.keys(service)) { + assert.equal(forbiddenDeployArtifactFields.has(field), false, `deploy service ${service.serviceId}.${field} must stay in artifact catalog`); + } + for (const envName of Object.keys(service.env ?? {})) { + assert.equal(forbiddenDeployArtifactEnv.has(envName), false, `deploy service ${service.serviceId}.env.${envName} must be rendered from artifact catalog`); + } + } assert.equal(catalog.environment, ENVIRONMENT_DEV, "catalog environment must be dev"); assert.equal(catalog.namespace, "hwlab-dev", "catalog namespace must be hwlab-dev"); @@ -475,13 +497,11 @@ function assertStaticContract(deploy, catalog) { assert.ok(catalog.forbiddenProfiles.includes("prod"), "catalog must forbid prod"); assert.deepEqual(catalog.services.map((service) => service.serviceId), SERVICE_IDS); assert.ok(unique(catalog.services.map((service) => service.serviceId)), "catalog service IDs must be unique"); - assert.equal(catalog.commitId, deploy.commitId, "catalog commitId must match deploy commitId"); const deployByService = new Map(deploy.services.map((service) => [service.serviceId, service])); for (const service of catalog.services) { const deployService = deployByService.get(service.serviceId); assert.ok(deployService, `${service.serviceId} missing from deploy manifest`); - assert.equal(service.image, deployService.image, `${service.serviceId} image mismatch`); assert.equal(service.namespace, "hwlab-dev", `${service.serviceId} namespace mismatch`); assert.equal(service.profile, ENVIRONMENT_DEV, `${service.serviceId} profile mismatch`); assert.equal(service.healthPath, "/health/live", `${service.serviceId} health path mismatch`); @@ -1117,12 +1137,12 @@ async function validateLiveProbes(reporter, catalog, timeoutMs) { } } -function validateRuntimeBoundary(reporter, deploy) { - const substituteImages = deploy.services +function validateRuntimeBoundary(reporter, catalog) { + const substituteImages = (catalog.services ?? []) .map((service) => service.image) .filter((image) => /unidesk|provider-gateway|microservice-proxy/.test(image)); if (substituteImages.length === 0) { - reporter.check("runtime-substitution-boundary", "safety", "pass", "Deploy images do not point at UniDesk/provider-gateway/microservice-proxy substitutes."); + reporter.check("runtime-substitution-boundary", "safety", "pass", "Catalog images do not point at UniDesk/provider-gateway/microservice-proxy substitutes."); return; } @@ -1130,7 +1150,7 @@ function validateRuntimeBoundary(reporter, deploy) { reporter.block({ type: "safety_blocker", scope: "runtime-boundary", - summary: "DEV deploy manifest references a forbidden non-HWLAB runtime substitute.", + summary: "DEV artifact catalog references a forbidden non-HWLAB runtime substitute.", nextTask: "Replace substitute runtime references with HWLAB-owned service images." }); } @@ -1245,7 +1265,7 @@ export async function runPreflight(argv) { validateArtifactPublishReport(reporter, optionalReports.artifactPublish, artifactIdentity, targetShortCommit, targetCommit, args.targetRef); validateEdgeHealthReport(reporter, optionalReports.edgeHealth); await validateEdgeContracts(reporter, masterEdge); - validateRuntimeBoundary(reporter, deploy); + validateRuntimeBoundary(reporter, catalog); await validateLiveProbes(reporter, catalog, args.timeoutMs); const report = makeReport(args, targetCommit, targetShortCommit, reporter, artifactIdentity, registryCapabilities); diff --git a/scripts/validate-artifact-catalog.mjs b/scripts/validate-artifact-catalog.mjs index c87e971e..b237b1a7 100644 --- a/scripts/validate-artifact-catalog.mjs +++ b/scripts/validate-artifact-catalog.mjs @@ -28,6 +28,37 @@ const requiredForbiddenItems = [ "unidesk-runtime-substitute", "force-push" ]; +const forbiddenDeployArtifactFields = new Set([ + "commitId", + "image", + "imageTag", + "digest", + "sourceCommitId", + "repositoryDigest", + "buildCreatedAt", + "buildSource", + "componentCommitId", + "componentInputHash", + "dockerfileHash", + "baseImageReference", + "baseImageDigest", + "buildArgsHash", + "ciAffected", + "ciReason", + "reuse", + "reusedFrom", + "publishState" +]); +const forbiddenDeployArtifactEnv = new Set([ + "HWLAB_COMMIT_ID", + "HWLAB_IMAGE", + "HWLAB_IMAGE_TAG", + "HWLAB_IMAGE_DIGEST", + "HWLAB_REVISION", + "HWLAB_BUILD_CREATED_AT", + "HWLAB_BUILD_SOURCE", + "HWLAB_SKILLS_COMMIT_ID" +]); async function readJSON(relativePath) { const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); @@ -124,7 +155,7 @@ function assertServiceInventory(catalog) { function assertDeployManifest(deployManifest, catalog) { assert.equal(deployManifest.manifestVersion, "v1", "deploy manifestVersion"); assert.equal(deployManifest.environment, ENVIRONMENT_DEV, "deploy environment"); - assert.equal(deployManifest.commitId, catalog.commitId, "deploy commitId must match catalog"); + assert.equal(Object.hasOwn(deployManifest, "commitId"), false, "deploy commitId is generated artifact identity and must stay in artifact catalog"); assert.equal(deployManifest.namespace, catalog.namespace, "deploy namespace must match catalog"); assert.equal(deployManifest.endpoint, catalog.endpoint, "deploy endpoint must match catalog"); assert.equal(deployManifest.profiles.dev.enabled, true, "deploy dev profile must be enabled"); @@ -132,6 +163,17 @@ function assertDeployManifest(deployManifest, catalog) { assert.equal(deployManifest.profiles.dev.endpoint, catalog.endpoint, "deploy dev endpoint must match catalog"); assert.ok(deployManifest.profiles.prod, "deploy may keep disabled prod placeholder"); assert.equal(deployManifest.profiles.prod.enabled, false, "deploy prod profile must stay disabled"); + for (const field of Object.keys(deployManifest)) { + assert.equal(forbiddenDeployArtifactFields.has(field), false, `deploy ${field} is generated artifact identity and must stay in artifact catalog`); + } + for (const service of deployManifest.services ?? []) { + for (const field of Object.keys(service)) { + assert.equal(forbiddenDeployArtifactFields.has(field), false, `deploy service ${service.serviceId}.${field} is generated artifact identity and must stay in artifact catalog`); + } + for (const envName of Object.keys(service.env ?? {})) { + assert.equal(forbiddenDeployArtifactEnv.has(envName), false, `deploy service ${service.serviceId}.env.${envName} is generated artifact identity and must be rendered from artifact catalog`); + } + } } function assertCatalogServices(catalog, deployManifest, catalogMode) { @@ -161,7 +203,6 @@ function assertCatalogServices(catalog, deployManifest, catalogMode) { const image = imageParts(service.image, `${context} image`); assert.equal(image.repositoryName, service.serviceId, `${context} image repository must end with serviceId`); assert.equal(image.tag, service.imageTag, `${context} image tag`); - assert.equal(service.image, deployService.image, `${context} image must match deploy manifest`); assert.equal(service.profile, ENVIRONMENT_DEV, `${context} profile`); assert.equal(service.profile, deployService.profile, `${context} profile must match deploy manifest`); diff --git a/scripts/validate-contract.mjs b/scripts/validate-contract.mjs index fa13ac82..4a86d85e 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -60,7 +60,7 @@ async function parseDeployManifest(relativePath) { const doc = JSON.parse(raw); assert.equal(doc.manifestVersion, "v1", `${relativePath} manifestVersion`); assert.equal(doc.environment, ENVIRONMENT_DEV, `${relativePath} environment`); - assert.ok(doc.commitId, `${relativePath} commitId`); + assert.equal(Object.hasOwn(doc, "commitId"), false, `${relativePath} commitId must stay in artifact catalog`); assert.ok(Array.isArray(doc.services) && doc.services.length >= 1, `${relativePath} services`); return doc; } @@ -77,16 +77,6 @@ function assertUnique(name, values) { assert.equal(new Set(values).size, values.length, `${name} must be unique`); } -function imageTagFromReference(image) { - if (typeof image !== "string" || image.length === 0) return null; - const digestIndex = image.indexOf("@"); - const withoutDigest = digestIndex >= 0 ? image.slice(0, digestIndex) : image; - const slashIndex = withoutDigest.lastIndexOf("/"); - const colonIndex = withoutDigest.lastIndexOf(":"); - if (colonIndex <= slashIndex) return null; - return withoutDigest.slice(colonIndex + 1) || null; -} - function assertCommonSchema(commonSchema) { const schemaServiceIds = commonSchema.$defs.serviceId.enum; assert.deepEqual(schemaServiceIds, SERVICE_IDS, "common service ids must match runtime constants"); @@ -101,9 +91,22 @@ function assertDeploySchema(deploySchema) { assert.ok(deploySchema.required.includes("publicEndpoints"), "deploy schema requires public endpoint source"); assert.ok(deploySchema.required.includes("frp"), "deploy schema requires frp source"); assert.ok(deploySchema.required.includes("k3s"), "deploy schema requires k3s source"); + assert.equal(deploySchema.required.includes("commitId"), false, "deploy schema must not require generated commitId"); + assert.equal(deploySchema.$defs.service.required.includes("image"), false, "deploy service schema must not require generated image"); assert.equal(deploySchema.$defs.health.properties.path.const, "/health/live", "deploy health path source"); } +function assertNoDeployArtifactIdentity(service, serviceId) { + const forbiddenServiceFields = ["image", "imageTag", "digest", "commitId", "sourceCommitId"]; + const forbiddenEnvFields = ["HWLAB_COMMIT_ID", "HWLAB_IMAGE", "HWLAB_IMAGE_TAG", "HWLAB_IMAGE_DIGEST", "HWLAB_REVISION", "HWLAB_BUILD_CREATED_AT", "HWLAB_BUILD_SOURCE", "HWLAB_SKILLS_COMMIT_ID"]; + for (const field of forbiddenServiceFields) { + assert.equal(Object.hasOwn(service, field), false, `${serviceId}.${field} must stay in artifact catalog or rendered runtime manifests`); + } + for (const field of forbiddenEnvFields) { + assert.equal(Object.hasOwn(service.env ?? {}, field), false, `${serviceId}.env.${field} must be rendered from artifact catalog`); + } +} + function assertEnvelopeValidation() { validateRequest({ jsonrpc: "2.0", @@ -187,10 +190,7 @@ assert.ok( "deploy edge proxy k3s mapping" ); assert.equal(cloudApi.healthPath, "/health/live", "cloud-api health path"); -const cloudApiImageTag = imageTagFromReference(cloudApi.image) ?? deployManifest.commitId.slice(0, 7); -assert.equal(cloudApi.env.HWLAB_COMMIT_ID, cloudApiImageTag, "cloud-api health commit evidence"); -assert.equal(cloudApi.env.HWLAB_IMAGE, cloudApi.image, "cloud-api health image evidence"); -assert.equal(cloudApi.env.HWLAB_IMAGE_TAG, cloudApiImageTag, "cloud-api health image tag evidence"); +for (const service of deployManifest.services) assertNoDeployArtifactIdentity(service, service.serviceId); assert.equal(cloudApi.env.HWLAB_CLOUD_DB_URL, "secretRef:hwlab-cloud-api-dev-db/database-url", "cloud-api DB URL must be a Secret reference placeholder"); assert.equal( cloudApi.env.HWLAB_CLOUD_DB_SSL_MODE,