Merge pull request #499 from pikasTech/fix/deploy-json-human-truth-20260527

fix: keep deploy manifest human-authored
This commit is contained in:
Lyon
2026-05-27 09:25:40 +08:00
committed by GitHub
16 changed files with 308 additions and 337 deletions
+12 -17
View File
@@ -67,25 +67,20 @@ node scripts/deploy-desired-state-plan.mjs --target-tag <tag> --pretty
node scripts/deploy-desired-state-plan.mjs --promotion-commit <sha> --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
+2 -24
View File
@@ -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"
+2 -10
View File
@@ -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
+25 -26
View File
@@ -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 `<registry>/<namespace>/<serviceId>:<imageTag>` 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 `<registry>/<namespace>/<serviceId>:<imageTag>`. 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 <tag> --pretty
node scripts/deploy-desired-state-plan.mjs --promotion-commit <sha> --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 <sha>
--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
+5 -3
View File
@@ -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/<hwlab-service-id>:<commit>`.
- `deploy/artifact-catalog.dev.json` pins service image names to the DEV
registry target, by default
`127.0.0.1:5000/hwlab/<hwlab-service-id>:<commit>`; `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.
+2 -2
View File
@@ -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
+4 -4
View File
@@ -66,9 +66,9 @@ HWLAB 是 monorepoG14 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 stateworkload 的 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 <short>` 提交回 `G14` source branchpoller 必须跳过这类生成提交,避免自触发循环。这样下一轮 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 stateworkload 的 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 <short>` 提交回 `G14` source branchpoller 必须跳过这类生成提交,避免自触发循环。promotion 不得自动修改 `deploy/deploy.json``deploy/k8s/base/workloads.yaml`,这样人写配置不会和 CI 生成身份反复冲突
- Tekton 并发化只能以 planner 输出作为输入;每个 service 都有独立 TaskRunchanged service 启动 BuildKitunchanged 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-outper-service TaskRun 应由 Tekton/k8s scheduler 同时调度,多个 service 的 build/reuse 窗口应接近“最慢单个服务任务耗时”,而不是所有服务串行相加。reuse-only 场景的 fan-out 窗口当前基线约为十几秒;真实组件构建场景仍需按 changed service 单独记录 BuildKit 耗时和 cache hit 情况。
- CD rolloutunchanged service 必须复用 `deploy.services[]` 的 image/digest/envpod template 不应因全局 source commit 改变而无意义滚动。手写 manifest(例如 `deepseek-proxy`、device-agent 类辅助 workload)只要复用某个已发布服务镜像,也必须走同一个 deploy catalog image 选择逻辑,不能直接用当前 source commit tag。
- CD rolloutunchanged service 必须复用 `deploy/artifact-catalog.dev.json` 的 image/digestpod 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 清单为准。
+31 -36
View File
@@ -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 () => {
+2 -2
View File
@@ -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", () => {
+92 -53
View File
@@ -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 };
+5 -67
View File
@@ -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,
+32 -60
View File
@@ -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",
@@ -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",
+32 -12
View File
@@ -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);
+43 -2
View File
@@ -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`);
+15 -15
View File
@@ -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,