feat: parallelize dev ci cd artifact flow

This commit is contained in:
Codex
2026-05-24 04:36:38 +00:00
parent 65583b79d7
commit 86e7df92a0
15 changed files with 1190 additions and 150 deletions
+2
View File
@@ -58,6 +58,8 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥
- Cloud Web M3 只读护栏:`npm run web:m3-readonly`
- Cloud Workbench 布局/遮挡 smoke`npm run web:layout`local-build 用 `npm run web:layout:build`DEV deploy 后用 `npm run web:layout:live`
- DEV artifact 发布预检:`npm run dev-artifact:preflight`
- DEV artifact CI 发布:`HWLAB_CI_ARTIFACT_RUN_ID=<run-id> npm run dev-artifact:publish`
- DEV 依赖 runtime base 构建:`npm run dev-runtime-base:build`
- DEV CD 单事务发布/应用/验证:`npm run dev-cd:apply`
- runner GitHub 可见性预检:`npm run runner:issue-visibility:preflight`
- D601 k3s 只读观测:`npm run d601:k3s:readonly`
+59 -21
View File
@@ -2,10 +2,16 @@
`scripts/dev-artifact-publish.mjs` is the DEV-only artifact preflight/build
backend for D601 local/internal registry artifacts. Publish side effects are
normally entered through `scripts/dev-cd-apply.mjs`, which sets the transaction
environment and serializes publish/apply/verify with the DEV CD Lease lock.
The artifact script does not deploy workloads, read secrets, enable PROD, or
push to GHCR/Docker Hub/other third-party registries.
normally entered by a CI artifact job identity, or by the legacy
`scripts/dev-cd-apply.mjs` transaction path during transition. The artifact
script does not deploy workloads, read secrets, enable PROD, or push to
GHCR/Docker Hub/other third-party registries.
长期口径:CI 产物报告不是人工维护的镜像库状态,也不是 CD 放行的本地状态源。
它是 CI 自动生成的一次性审计记录。CD 必须从 `deploy/deploy.json` 读取发布控制
意图,从 `deploy/artifact-catalog.dev.json` 读取 digest 期望,并直接向 registry
manifest 验证目标 tag/digest 是否存在。`/tmp/hwlab-dev-gate/dev-artifacts.json`
只能用于排障、刷新 catalog 和审计追溯。
## Scope
@@ -20,9 +26,10 @@ push to GHCR/Docker Hub/other third-party registries.
`--pull=false`.
- Node runtime dependencies are installed from the source `package.json` and
`package-lock.json` inside the DEV image build with `npm ci --omit=dev
--ignore-scripts` when the lockfile is present. This includes the Postgres
`pg` driver required by the
durable cloud-api runtime adapter.
--ignore-scripts` when the lockfile is present, unless the selected base
image already provides `/opt/hwlab-node-runtime-base/node_modules`. This
includes the Postgres `pg` driver required by the durable cloud-api runtime
adapter.
- Image tag: first seven characters of the Git commit used as build source.
- Required image labels:
- `hwlab.pikastech.local/repo`
@@ -40,7 +47,9 @@ node --check scripts/src/dev-base-image-preflight.mjs
node --check scripts/src/dev-artifact-services.mjs
node --check scripts/src/registry-capabilities.mjs
node --check scripts/dev-artifact-publish.mjs
node --check scripts/dev-runtime-base-image.mjs
node --check scripts/refresh-artifact-catalog.mjs
node --test scripts/refresh-artifact-catalog.test.mjs
```
Base image preflight:
@@ -61,22 +70,31 @@ Build only:
node scripts/dev-artifact-publish.mjs --build
```
Build and publish to the D601 local/internal registry inside the DEV CD
transaction:
Build only one or more selected services for a CI artifact task:
```sh
node scripts/dev-artifact-publish.mjs --build --services hwlab-cloud-web
```
`--services` narrows the build/publish report to selected service IDs. It is a
CI/artifact acceleration surface; the normal DEV rollout still consumes
repo-owned desired state through `scripts/dev-cd-apply.mjs`.
Build and publish to the D601 local/internal registry inside a CI artifact job:
```sh
HWLAB_CI_ARTIFACT_RUN_ID=<run-id> node scripts/dev-artifact-publish.mjs --publish
```
The legacy transaction entry remains available during migration:
```sh
node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json
```
The underlying single-step command is kept only for transaction internals and
specialized tests that explicitly inject the transaction environment:
```sh
node scripts/dev-artifact-publish.mjs --publish
```
Direct `--publish` without `HWLAB_CD_TRANSACTION_ID` is rejected with
`cd-transaction-required`; this is a bypass guard, not a second lock.
Direct `--publish` without `HWLAB_CI_ARTIFACT_RUN_ID` or
`HWLAB_CD_TRANSACTION_ID` is rejected with `cd-transaction-required`; this is a
bypass guard, not a second lock.
Daily Cloud Web source validation does not require ignored `dist/` to exist:
@@ -107,12 +125,32 @@ registry prefix. The script rejects third-party registry hosts and any prefix
that names PROD. Use `--base-image` only to override `HWLAB_DEV_BASE_IMAGE` with
an approved local image for that run.
Docker build output is visible by default so a slow layer is observable from the
runner log. Use `--quiet-build` only when a caller already captures the full
Docker log elsewhere. The artifact report records per-service
`dockerBuildDurationMs`, `cloudWebBuildDurationMs`, and `publishDurationMs`.
An optional dependency base image can be prepared with:
```sh
node scripts/dev-runtime-base-image.mjs --dry-run
node scripts/dev-runtime-base-image.mjs
```
The default tag is
`127.0.0.1:5000/hwlab/hwlab-node-runtime-base:deps-<package-lock-hash>`.
Selecting that image through `HWLAB_DEV_BASE_IMAGE` lets service builds reuse a
preinstalled `node_modules` layer instead of repeating `npm ci` for every
service.
## Report
The script writes `/tmp/hwlab-dev-gate/dev-artifacts.json`. The file is kept in the
existing DEV gate report envelope for compatibility with
The script writes `/tmp/hwlab-dev-gate/dev-artifacts.json`. The file is kept in
the existing DEV gate report envelope for compatibility with
`scripts/validate-dev-gate-report.mjs`, while the `artifactPublish` object is
the HWLAB#35-specific publish record.
the HWLAB#35-specific publish record. This report is evidence, not desired
state. A later CD rollout must verify catalog images directly against the
registry instead of trusting this file.
Each service record contains:
+17 -4
View File
@@ -57,10 +57,10 @@ Selection order is intentionally narrow:
tag such as `127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim`.
4. If neither source is available, the result is a blocker.
Explicit base image values must match local `node:20-*`, `hwlab-dev-base:*`, or
`hwlab-node20-base:*` references. They must not reference UniDesk, Code Queue,
`backend-core`, `provider-gateway`, `microservice-proxy`, or an HWLAB runtime
service image. A service image such as
Explicit base image values must match local `node:20-*`, `hwlab-dev-base:*`,
`hwlab-node20-base:*`, or `hwlab-node-runtime-base:*` references. They must not
reference UniDesk, Code Queue, `backend-core`, `provider-gateway`,
`microservice-proxy`, or an HWLAB runtime service image. A service image such as
`127.0.0.1:5000/hwlab/hwlab-cloud-api:<sha>` is an artifact output, not a
builder base image.
@@ -113,6 +113,19 @@ docker tag node:20-bookworm-slim 127.0.0.1:5000/hwlab/hwlab-dev-base:node20-book
HWLAB_DEV_BASE_IMAGE=127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim node scripts/preflight-dev-base-image.mjs
```
Optional dependency runtime base provision:
```sh
node scripts/dev-runtime-base-image.mjs --dry-run
node scripts/dev-runtime-base-image.mjs
HWLAB_DEV_BASE_IMAGE=127.0.0.1:5000/hwlab/hwlab-node-runtime-base:deps-<hash> node scripts/preflight-dev-base-image.mjs
```
This runtime base caches `package.json` / `package-lock.json` dependencies in
`/opt/hwlab-node-runtime-base/node_modules`. Service artifact builds copy that
directory when present, which avoids repeating the same `npm ci` layer for each
service.
Offline preload path when D601 cannot pull directly:
```sh
+106 -28
View File
@@ -43,7 +43,7 @@ truth.
| Stage | Current HWLAB state | Required next behavior |
| --- | --- | --- |
| 1. Long-term reference | This file records DEV deployment, Cloud Web publish, API/edge/health, frp, D601 registry, k3s rollout, rollback, and verification rules. `AGENTS.md` indexes it. | Keep process issues as sources only; update this reference when a deployment rule changes. |
| 2. CLI or controlled scripts | `scripts/dev-cd-apply.mjs` is the only formal DEV CD side-effect entrypoint for publish, desired-state refresh, apply, and live verify. Other controlled scripts still cover contract planning, dry-run planning, preflight, single-step internals, k3s read-only visibility, and Cloud Web checks. Rollback is documented as commands/report hints but is not yet one unified CLI command. | Converge check, plan, rollback, status, and verify into HWLAB CLI while preserving the single transaction lock for side effects backed by `deploy/deploy.json`. |
| 2. CLI or controlled scripts | `scripts/dev-artifact-publish.mjs` is the formal CI artifact publish entrypoint under CI identity; `scripts/dev-cd-apply.mjs` is the formal DEV CD side-effect entrypoint for desired-state apply and live verify. Other controlled scripts still cover contract planning, dry-run planning, preflight, single-step internals, k3s read-only visibility, and Cloud Web checks. Rollback is documented as commands/report hints but is not yet one unified CLI command. | Converge check, plan, rollback, status, and verify into HWLAB CLI while preserving CI/CD separation and the single CD transaction lock backed by `deploy/deploy.json`. |
| 3. UniDesk CI/CD plus images | DEV services are imageized and published to the D601 registry. D601 rollout still includes manual bridge steps for Cloud Web and frp/k3s execution. | Move build, publish, desired-state refresh, rollout, post-deploy validation, and report upload into standard UniDesk CI/CD jobs without replacing HWLAB runtime. |
## DEV CD Transaction
@@ -54,8 +54,10 @@ truth.
node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json
```
该命令是唯一允许一次性发布 DEV artifact、从发布报告刷新 desired
state、apply 到 `hwlab-dev`、并复验公开 `16666/16667` 的正式写路径。
该命令是唯一允许`deploy.json``hwlab-dev` 执行 desired-state apply、并复验
公开 `16666/16667` 的正式 CD 写路径。镜像发布属于 CI artifact 职责;过渡期
latest-main 兼容路径仍可由该命令调用 artifact publish,但正式 rollout 默认应消费
已经发布并经过 registry manifest 验证的 artifact。
事务开始时读取 `deploy/deploy.json`,记录 manifest hash 和 target commit。
在获取 `Lease/hwlab-dev/hwlab-dev-cd-lock` 或运行任何 publish、runtime Job、
apply、rollout 类副作用前,脚本必须先完成 apply-only preflight:强制
@@ -90,14 +92,17 @@ checkout promotion parent 或手工拼装发布环境。
`origin/main` 控制入口,`target.promotionCommit` 是从 `deploy/deploy.json`
解析出的真实 promotion commit`target.artifactBoundary` 记录最近一次触碰
`deploy/deploy.json``deploy/artifact-catalog.dev.json`
`deploy/k8s/base/workloads.yaml``/tmp/hwlab-dev-gate/dev-artifacts.json`
first-parent artifact merge commit,以及该 merge commit 的 promotion parent
和 artifact/report parent。`artifactCatalog``artifactReport``deployJson`
分别记录 hash、commit、digest 计数和 publish source。若 deploy/catalog/report
或 artifact merge parent 不能同时指向同一个 promotion commit,状态降级为
`degraded`,真实 `--apply` 在获取 Lease 前以 artifact-boundary blocker 停止。
这保证 `deploy/deploy.json` 仍是唯一发布控制源;Lease 只串行化事务,不保存第
二套 desired-state。
`deploy/k8s/base/workloads.yaml` 的 first-parent desired-state commit,以及该
commit 的 promotion parent`artifactCatalog``artifactReport``deployJson`
分别记录 hash、commit、digest 计数和 publish source,但只有
`deploy/deploy.json`、artifact catalog、workload manifest 和 registry manifest
共同决定 CD 是否可执行;`/tmp/hwlab-dev-gate/dev-artifacts.json` 只是 CI 审计快照。
若 deploy/catalog/workloads 或 desired-state parent 不能同时指向同一个
promotion commit,状态降级为 `degraded`,真实 `--apply` 在获取 Lease 前以
artifact-boundary blocker 停止。缺失或过期的 CI 产物报告只能产生诊断提示,不得
阻塞已经由 desired-state 和 registry 证明一致的 CD rollout。这保证
`deploy/deploy.json` 仍是唯一发布控制源;Lease 只串行化事务,不保存第二套
desired-state。
Lease 只是发布平面的互斥锁,不是 desired-state 来源。Lease annotations
至少记录 `promotionCommit``deployJsonHash``ownerTaskId`
@@ -145,7 +150,7 @@ repo-owned 入口,不能绕过它们直接拼接 publish、apply、rollout 或
| 读取 DEV CD 状态 | `node scripts/dev-cd-apply.mjs --status` | 不写入、不获取 Lease、不读取 Secret value。 |
| 事务 dry-run preflight | `node scripts/dev-cd-apply.mjs --dry-run` | 不 publish、不 apply、不 rollout。 |
| 检查 desired-state 收敛 | `node scripts/deploy-desired-state-plan.mjs --promotion-commit <sha> --check` | 只读 source/report。 |
| 执行 DEV CD 事务 | `node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json` | 仅 DEV,在 `Lease/hwlab-dev/hwlab-dev-cd-lock`publish/apply/verify。 |
| 执行 DEV CD 事务 | `node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json` | 仅 DEV,在 `Lease/hwlab-dev/hwlab-dev-cd-lock` 下 apply/verifylegacy latest-main publish 仅作过渡兼容。 |
| 读取 D601 k3s 证据 | `node scripts/d601-k3s-readonly-observability.mjs` | 只读可见性报告;不 rollout、不输出 Secret value。 |
使用 master-side wrapper 时,稳定形态是:
@@ -179,9 +184,10 @@ desired-state 来源关系固定如下:
| 来源 | 权威范围 |
| --- | --- |
| `deploy/deploy.json` | DEV environment、namespace、endpoint、service set、promotion commit 和事务消费的 image tag。 |
| `deploy/artifact-catalog.dev.json` | 同一 promotion commit 的不可变 artifact digest/tag 证据。 |
| `deploy/artifact-catalog.dev.json` | 同一 promotion commit 的不可变 artifact digest/tag 期望;CD 必须直接向 registry 校验 tag/digest 是否真实存在。 |
| `deploy/k8s/base/workloads.yaml` | Kubernetes workload desired state,必须镜像 `deploy.json` 和 artifact catalog 后才能 apply。 |
| `/tmp/hwlab-dev-gate/*.json` | 只作为证据快照;不能覆盖 repo desired state。 |
| Registry manifest | 镜像事实源;回答目标 tag/digest 是否存在,不能决定哪一组服务应该上线。 |
| `/tmp/hwlab-dev-gate/*.json` | 只作为 CI/CD 审计、排障和耗时统计快照;不能覆盖 repo desired state,也不能成为 CD 放行硬依赖。 |
| `Lease/hwlab-dev/hwlab-dev-cd-lock` | 只作为事务互斥锁和审计状态;永远不是 desired-state 来源。 |
只改文档的 commit 如果没有修改 `deploy/deploy.json`
@@ -204,6 +210,75 @@ live health verification 或看板/status 收口权限。Runner 发布 artifact
输出必须包含 tag、digest 和 report 证据,并把 desired-state convergence 与
live rollout evidence 分开。
## CI/CD 职责拆分
长期稳定规则是 CI 负责构建和发布镜像,CD 只消费已经构建好的镜像和
repo-owned desired state。CD 入口不得临场重新解释 source、手工拼装 build
env、重新构建镜像或把构建失败伪装成 rollout blocker。
CI/CD 三源分工固定如下:
| 来源 | 职责 | 不能承担 |
| --- | --- | --- |
| `deploy/deploy.json` | 唯一发布控制源,声明 promotion commit、service image tag 和 DEV 目标。 | 证明镜像真实存在。 |
| Registry manifest | 镜像事实源,证明目标 tag/digest 可被 registry 读取,并与 catalog 记录一致。 | 决定哪一批服务应该作为一个发布集合上线。 |
| CI 产物报告 | 自动生成的一次性审计记录,包含构建、推送、digest、耗时和日志摘要。 | 作为手工维护状态表,或成为 CD rollout 的必要本地文件。 |
`deploy/artifact-catalog.dev.json``deploy.json` 与 registry 之间的 repo-owned
digest 期望表;`deploy/k8s/base/workloads.yaml` 是最终 Kubernetes desired state。
CD 在 apply 前必须根据 catalog 的 image tag/digest 直接读取 registry manifest
确认镜像事实存在且 digest 匹配。过期、缺失或路径漂移的
`/tmp/hwlab-dev-gate/dev-artifacts.json` 只能帮助定位 CI 问题,不得推翻
`deploy.json + catalog + registry` 已经证明一致的发布结论。
过渡期如果 `scripts/dev-cd-apply.mjs` 发现 `deploy/deploy.json` 已经指向一个
通过 desired-state check 的 promotion commit,应走 `promotionSource=deploy-json`
路径,只执行 desired-state 校验、runtime 前置、apply、postflight 和 live verify
不得重新 publish 当前 HEAD。未来 CI 常态化后,latest-main target-ref publish
路径应降级为受限兼容路径,正式 DEV rollout 默认走已发布 artifact 的
deploy-json promotion。
CI/artifact 层可以使用以下加速面:
- `scripts/dev-artifact-publish.mjs --build --services <id,...>`:只构建受影响服务,避免 web-only 变更重建所有服务;
- `scripts/dev-artifact-publish.mjs --publish --services <id,...>`:只在 artifact 任务边界内发布选定服务并记录 digest;
- `scripts/dev-artifact-publish.mjs --publish --concurrency <n>`:并行构建/推送服务镜像,默认 4,最高 8;
- `scripts/dev-runtime-base-image.mjs`:按 `package.json` / `package-lock.json`
hash 构建 `hwlab-node-runtime-base:deps-<hash>`,让后续服务镜像复用依赖层。
CD 层不得把上述 CI/artifact 加速面当作 rollout 授权。只有当 desired-state
文件已经收敛、registry manifest 直接验证通过,CD 才能拿 Lease 执行 DEV apply。
CD apply 内部可以用 `--rollout-concurrency <n>``HWLAB_DEV_CD_CONCURRENCY`
并行执行 registry manifest 验证、DEV allowlist 清理和 post-apply rollout status
并发只加速同一事务内的独立检查,不创建第二个 CD 控制源。
## Git Repo Owner 权限治理
GitHub repo owner/admin 权限是治理权限,不是日常构建、合并、发布和清理权限。
长期目标是把 owner 从常规交付链路中解耦,避免 owner 账号或 owner-owned
工作区成为单点阻塞,也避免 root/owner 文件污染让 runner、CI 或 CD 无法继续。
稳定分工如下:
| 权限域 | 可以做什么 | 不应承担什么 |
| --- | --- | --- |
| Repo owner/admin | 维护 branch protection、required checks、runner/App/token 授权、紧急权限恢复和审计规则。 | 日常手工 build、长期持有脏 worktree、替 runner 修权限污染、绕过 PR 合并主线。 |
| CI build identity | 从受保护主线或 PR commit 构建镜像,写 CI artifact、digest report 和受控 PR/commit。 | DEV apply、live rollout、看板收口、读取 Secret value。 |
| CD deploy identity | 读取已合并 desired-state 和 registry artifact,获取 DEV Lease,执行 apply/verify。 | 构建镜像、修改 source、改写 `deploy.json` promotion commit。 |
| Code Queue runner | 在任务分支完成代码/文档/desired-state PR,运行只读 status/preflight 和授权的 artifact 任务。 | 占用 owner 权限、写 main、污染共享发布 worktree、承担长期 CD lock。 |
所有自动化应使用可审计、可轮换的 GitHub App 或 fine-grained tokentoken 只授予
所需 repo 和最小 scope。日常主线合并通过 branch protection 和 required CI
结果完成,owner/admin 只处理策略变更、紧急解锁和权限恢复。至少保留两个维护者
具备 owner/admin 恢复能力,避免单人离线造成 repo 或部署链路停摆。
共享发布目录只允许作为缓存或 clean mirror,不允许成为 source of truth。任何
CI/CD 写操作都应在 ephemeral worktree 或明确 owner 的 clean worktree 中执行;
进入写路径前必须检查 `git status --short``.git` 可读写权限、工作区 owner/group
和 expected remote commit。发现 root-owned 文件、不可快进、未知本地修改或权限
不一致时,应结构化报 `environment_blocker`,由权限治理流程修复;不得直接在污染
工作区继续 build、apply 或 push。
## SecretRef Preflight
apply 前还必须把必要 SecretRef 作为 preflight 条件处理,而不是等待
@@ -269,11 +344,11 @@ value。
完整 JSON。DEV apply、rollout 和 live verification 仍由 host commander
统一执行;runner 默认只使用 status/dry-run 观测面,不竞争 DEV CD lock。
旧的 side-effect 命令已经降为事务内部步骤。直接手动执行
`scripts/dev-artifact-publish.mjs --publish`
`scripts/dev-deploy-apply.mjs --apply` 且没有 `HWLAB_CD_TRANSACTION_ID` 时,
会以 `cd-transaction-required` 拒绝;preflight、build、dry-run、check 和
read-only 模式仍可用。
旧的 deploy side-effect 命令已经降为事务内部步骤。直接手动执行
`scripts/dev-deploy-apply.mjs --apply` 且没有 `HWLAB_CD_TRANSACTION_ID` 时,会以
`cd-transaction-required` 拒绝。artifact publish 是 CI 职责,正式入口需要
`HWLAB_CI_ARTIFACT_RUN_ID`;过渡期也接受 `HWLAB_CD_TRANSACTION_ID`。preflight、
build、dry-run、check 和 read-only 模式仍可用。
## Artifact Publish
@@ -283,15 +358,16 @@ DEV artifacts publish to the D601 local/internal registry:
127.0.0.1:5000/hwlab/*
```
The single-step publish command is a transaction-internal side-effect step:
The single-step publish command is a CI artifact side-effect step:
```sh
node scripts/dev-artifact-publish.mjs --publish
HWLAB_CI_ARTIFACT_RUN_ID=<run-id> node scripts/dev-artifact-publish.mjs --publish
```
It only runs in side-effect mode when `HWLAB_CD_TRANSACTION_ID` is set by
`scripts/dev-cd-apply.mjs`. Use `node scripts/dev-cd-apply.mjs --apply
--confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json` for normal DEV CD.
It only runs in side-effect mode when `HWLAB_CI_ARTIFACT_RUN_ID` is set by the
CI artifact job, or when the legacy `HWLAB_CD_TRANSACTION_ID` is set by
`scripts/dev-cd-apply.mjs` during transition. Normal DEV CD must consume
`deploy.json` and registry-verified catalog state; it should not rebuild images.
For Cloud Web source freshness before the transaction:
@@ -496,9 +572,10 @@ use the repo-owned scripts below instead of ad hoc shell fragments.
| Plan desired image/workload state | `node scripts/deploy-desired-state-plan.mjs --plan --pretty` and `node scripts/deploy-desired-state-plan.mjs --promotion-commit <sha> --check` | Fold into `hwlab deploy plan --env dev --file deploy/deploy.json` with a promotion commit gate. |
| Observe DEV CD status | `node scripts/dev-cd-apply.mjs`, `node scripts/dev-cd-apply.mjs --status`, or `node scripts/dev-cd-apply.mjs --dry-run` | Keep read-only for host and runners; no Lease mutation, artifact publish, apply, rollout, report write, or Secret read. |
| Preflight artifact publish | `node scripts/dev-artifact-publish.mjs --preflight` | Keep as backend for `hwlab artifact publish --preflight`. |
| Prepare dependency runtime base | `node scripts/dev-runtime-base-image.mjs --dry-run` and `node scripts/dev-runtime-base-image.mjs` | Move into a CI base-image job keyed by dependency hash; CD only consumes the resulting image tag. |
| Publish/apply/verify DEV | `node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json` | Keep as the single side-effect transaction and run from repo-owned DEV CI/CD. |
| Publish DEV images | `node scripts/dev-artifact-publish.mjs --publish` inside `HWLAB_CD_TRANSACTION_ID` | Internal step only; direct side-effect calls are rejected. |
| Refresh catalog/desired state | `node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report /tmp/hwlab-dev-gate/dev-artifacts.json` | Internal transaction step after publish; read/write desired-state only from repo files. |
| Publish DEV images | `HWLAB_CI_ARTIFACT_RUN_ID=<run-id> node scripts/dev-artifact-publish.mjs --publish` | CI artifact step only; legacy CD transaction env remains transitional. |
| Refresh catalog/desired state | `node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report /tmp/hwlab-dev-gate/dev-artifacts.json` | CI promotion step after publish; report is input evidence for catalog refresh, not CD rollout state. |
| Apply DEV rollout | `node scripts/dev-deploy-apply.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json` inside `HWLAB_CD_TRANSACTION_ID` | Internal transaction step; direct side-effect calls are rejected. |
| Roll back DEV Deployment | `KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev rollout undo deployment/<name>` | Add `hwlab deploy rollback --env dev --service <id> --to-revision <n>`. |
| Verify status | `curl` checks plus `node scripts/d601-k3s-readonly-observability.mjs` | Add `hwlab deploy verify --env dev --report /tmp/hwlab-dev-gate/report.json`. |
@@ -512,9 +589,10 @@ converge manual operations into CLI plus `deploy/deploy.json`, then run that
surface from UniDesk CI/CD:
- render and verify frps/frpc from the deploy manifest;
- publish images and write digest reports;
- refresh catalog/workload desired state from the publish report;
- publish images and write digest reports as CI audit evidence;
- refresh catalog/workload desired state from a verified CI publish report;
- apply DEV with immutable Job replacement policy;
- verify catalog tag/digest directly against registry manifests before apply;
- verify `16666` Cloud Web and `16667/health/live` API;
- invalidate or regenerate active reports after endpoint or artifact changes.
+3 -2
View File
File diff suppressed because one or more lines are too long
+129 -47
View File
@@ -16,7 +16,10 @@ import {
resolveDevArtifactServices,
serviceInventoryFromServices
} from "./src/dev-artifact-services.mjs";
import { requireDevCdTransactionForSideEffect } from "./src/dev-cd-transaction-guard.mjs";
import {
devArtifactProducer,
requireDevArtifactSideEffectEnv
} from "./src/dev-cd-transaction-guard.mjs";
import { runDevBaseImagePreflight } from "./src/dev-base-image-preflight.mjs";
import { probeRegistryCapabilities } from "./src/registry-capabilities.mjs";
import { inspectCloudWebDistFreshness } from "../web/hwlab-cloud-web/scripts/dist-contract.mjs";
@@ -66,7 +69,9 @@ function parseArgs(argv) {
baseImage: defaultBaseImage,
reportPath: defaultReportPath,
services: [...SERVICE_IDS],
emitReport: true
emitReport: true,
quietBuild: false,
concurrency: parseConcurrency(process.env.HWLAB_DEV_ARTIFACT_CONCURRENCY, 4)
};
for (let index = 0; index < argv.length; index += 1) {
@@ -79,6 +84,8 @@ function parseArgs(argv) {
else if (arg === "--report") args.reportPath = readOption(argv, ++index, arg);
else if (arg === "--services") args.services = readOption(argv, ++index, arg).split(",").map((item) => item.trim()).filter(Boolean);
else if (arg === "--no-report") args.emitReport = false;
else if (arg === "--quiet-build") args.quietBuild = true;
else if (arg === "--concurrency") args.concurrency = parseConcurrency(readOption(argv, ++index, arg), 4);
else if (arg === "--help" || arg === "-h") args.help = true;
else throw new Error(`unknown argument ${arg}`);
}
@@ -94,6 +101,12 @@ function readOption(argv, index, name) {
return value;
}
function parseConcurrency(value, fallback) {
const parsed = Number.parseInt(String(value ?? ""), 10);
if (!Number.isInteger(parsed)) return fallback;
return Math.min(Math.max(parsed, 1), 8);
}
function printHelp() {
console.log([
"usage: node scripts/dev-artifact-publish.mjs [--preflight|--build|--publish]",
@@ -105,7 +118,9 @@ function printHelp() {
" --base-image IMAGE override HWLAB_DEV_BASE_IMAGE for this run; must already be local",
" --services LIST comma-separated service IDs; default: all frozen DEV services",
` --report PATH default: ${defaultReportPath}`,
" --no-report print JSON without updating the temporary artifact JSON"
" --no-report print JSON without updating the temporary artifact JSON",
" --quiet-build keep docker build quiet; default keeps build output visible",
" --concurrency N parallel service build/push workers; default: 4, max: 8"
].join("\n"));
}
@@ -114,7 +129,7 @@ async function readJson(relativePath) {
}
function emit(event, payload = {}) {
process.stderr.write(`${JSON.stringify({ event, ...payload })}\n`);
process.stderr.write(`${JSON.stringify({ event, at: new Date().toISOString(), ...payload })}\n`);
}
function blocker({ type, scope, summary, next }) {
@@ -186,6 +201,7 @@ function commandLine(command, args) {
}
async function run(command, args, options = {}) {
const startedAt = Date.now();
const child = spawn(command, args, {
cwd: options.cwd ?? repoRoot,
env: options.env ?? process.env,
@@ -210,7 +226,8 @@ async function run(command, args, options = {}) {
command: commandLine(command, args),
code,
stdout,
stderr
stderr,
durationMs: Date.now() - startedAt
};
}
@@ -802,6 +819,28 @@ function dockerfile(baseImage, port) {
return [
`FROM ${baseImage}`,
"WORKDIR /app",
"ENV CODEX_HOME=/codex-home",
"ENV HWLAB_CODE_AGENT_CODEX_HOME=/codex-home",
"ENV HWLAB_CODE_AGENT_WORKSPACE=/workspace/hwlab",
"ENV HWLAB_CODE_AGENT_CODEX_WORKSPACE=/workspace/hwlab",
"ENV HWLAB_CODE_AGENT_CODEX_SANDBOX=workspace-write",
"ENV HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED=1",
"ENV HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR=repo-owned",
"ENV HWLAB_CODE_AGENT_CODEX_COMMAND=/app/node_modules/.bin/codex",
"ENV HWLAB_CODE_AGENT_PROVIDER=codex-stdio",
"ENV HWLAB_CODE_AGENT_SKILLS_DIRS=/app/skills:/root/.agents/skills:/home/ubuntu/.agents/skills",
"COPY package.json ./package.json",
"COPY package-lock.json* ./",
"RUN if [ -d /opt/hwlab-node-runtime-base/node_modules ]; then cp -a /opt/hwlab-node-runtime-base/node_modules ./node_modules; elif [ -f package-lock.json ]; then npm ci --omit=dev --ignore-scripts; else npm install --omit=dev --ignore-scripts; fi",
"COPY internal ./internal",
"COPY cmd ./cmd",
"COPY scripts ./scripts",
"COPY web ./web",
"COPY tools ./tools",
"COPY skills ./skills",
"COPY deploy ./deploy",
"RUN mkdir -p /workspace /codex-home && rm -rf /workspace/hwlab && ln -s /app /workspace/hwlab && chmod -R a+rwX /app /workspace /codex-home && test -x /app/node_modules/.bin/codex && /app/node_modules/.bin/codex --version >/tmp/hwlab-codex-version.txt && (test -x /app/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex/codex || test -x /app/node_modules/@openai/codex-linux-arm64/vendor/aarch64-unknown-linux-musl/codex/codex)",
`RUN node -e "const fs=require('node:fs'); fs.writeFileSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', Buffer.from('${runtimeScriptBase64()}', 'base64')); fs.chmodSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', 0o755);"`,
"ARG HWLAB_ENVIRONMENT",
"ARG HWLAB_SERVICE_ID",
"ARG HWLAB_ARTIFACT_KIND",
@@ -816,16 +855,6 @@ function dockerfile(baseImage, port) {
"ARG HWLAB_BUILD_PROVENANCE",
"ARG PORT",
"ARG HWLAB_PORT",
"ENV CODEX_HOME=/codex-home",
"ENV HWLAB_CODE_AGENT_CODEX_HOME=/codex-home",
"ENV HWLAB_CODE_AGENT_WORKSPACE=/workspace/hwlab",
"ENV HWLAB_CODE_AGENT_CODEX_WORKSPACE=/workspace/hwlab",
"ENV HWLAB_CODE_AGENT_CODEX_SANDBOX=workspace-write",
"ENV HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED=1",
"ENV HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR=repo-owned",
"ENV HWLAB_CODE_AGENT_CODEX_COMMAND=/app/node_modules/.bin/codex",
"ENV HWLAB_CODE_AGENT_PROVIDER=codex-stdio",
"ENV HWLAB_CODE_AGENT_SKILLS_DIRS=/app/skills:/root/.agents/skills:/home/ubuntu/.agents/skills",
"ENV HWLAB_ENVIRONMENT=$HWLAB_ENVIRONMENT",
"ENV HWLAB_SERVICE_ID=$HWLAB_SERVICE_ID",
"ENV HWLAB_ARTIFACT_KIND=$HWLAB_ARTIFACT_KIND",
@@ -840,18 +869,6 @@ function dockerfile(baseImage, port) {
"ENV HWLAB_BUILD_PROVENANCE=$HWLAB_BUILD_PROVENANCE",
"ENV PORT=$PORT",
"ENV HWLAB_PORT=$HWLAB_PORT",
"COPY package.json ./package.json",
"COPY package-lock.json* ./",
"RUN if [ -f package-lock.json ]; then npm ci --omit=dev --ignore-scripts; else npm install --omit=dev --ignore-scripts; fi",
"COPY internal ./internal",
"COPY cmd ./cmd",
"COPY scripts ./scripts",
"COPY web ./web",
"COPY tools ./tools",
"COPY skills ./skills",
"COPY deploy ./deploy",
"RUN mkdir -p /workspace /codex-home && rm -rf /workspace/hwlab && ln -s /app /workspace/hwlab && chmod -R a+rwX /app /workspace /codex-home && test -x /app/node_modules/.bin/codex && /app/node_modules/.bin/codex --version >/tmp/hwlab-codex-version.txt && (test -x /app/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex/codex || test -x /app/node_modules/@openai/codex-linux-arm64/vendor/aarch64-unknown-linux-musl/codex/codex)",
`RUN node -e "const fs=require('node:fs'); fs.writeFileSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', Buffer.from('${runtimeScriptBase64()}', 'base64')); fs.chmodSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', 0o755);"`,
`EXPOSE ${port}`,
"CMD [\"node\", \"/usr/local/bin/hwlab-dev-artifact-runtime.mjs\"]",
""
@@ -1022,7 +1039,8 @@ async function buildService({ args, repo, commitId, shortCommit, service, buildC
summary: "cloud web dist build failed before Docker build",
next: "Run node web/hwlab-cloud-web/scripts/build.mjs, fix the static asset build, then rerun --publish."
}),
logTail: tailText(`${build.stdout}\n${build.stderr}`)
logTail: tailText(`${build.stdout}\n${build.stderr}`),
cloudWebBuildDurationMs: build.durationMs
};
}
@@ -1048,7 +1066,8 @@ async function buildService({ args, repo, commitId, shortCommit, service, buildC
service = {
...service,
distFreshness
distFreshness,
cloudWebBuildDurationMs: build.durationMs
};
}
@@ -1083,11 +1102,9 @@ async function buildService({ args, repo, commitId, shortCommit, service, buildC
];
const argsList = [
"build",
"--pull=false",
"--quiet",
"--build-arg",
`BASE_IMAGE=${args.baseImage}`
"--pull=false"
];
if (args.quietBuild) argsList.push("--quiet");
for (const [name, value] of labels) {
argsList.push("--label", `${name}=${value}`);
@@ -1113,9 +1130,11 @@ async function buildService({ args, repo, commitId, shortCommit, service, buildC
summary: `docker build failed for ${service.serviceId}`,
next: `Inspect the Docker build output for ${service.serviceId}, then fix its copied source or base image.`
}),
logTail: tailText(`${result.stdout}\n${result.stderr}`)
logTail: tailText(`${result.stdout}\n${result.stderr}`),
dockerBuildDurationMs: result.durationMs
};
}
const imageInspect = await run("docker", ["image", "inspect", "--format", "{{.Id}}", ref]);
return {
...service,
@@ -1124,12 +1143,17 @@ async function buildService({ args, repo, commitId, shortCommit, service, buildC
buildCreatedAt,
buildSource,
status: "built",
localImageId: result.stdout.trim(),
localImageId: imageInspect.code === 0 ? imageInspect.stdout.trim() : null,
dockerBuildDurationMs: result.durationMs,
dockerImageInspectDurationMs: imageInspect.durationMs,
dockerImageInspectStatus: imageInspect.code === 0 ? "pass" : "failed",
dockerBuildLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200),
digest: "not_published"
};
}
async function runWithInput(command, args, input) {
const startedAt = Date.now();
const child = spawn(command, args, {
cwd: repoRoot,
env: process.env,
@@ -1155,7 +1179,8 @@ async function runWithInput(command, args, input) {
command: commandLine(command, args),
code,
stdout,
stderr
stderr,
durationMs: Date.now() - startedAt
};
}
@@ -1175,7 +1200,8 @@ async function publishService(artifact) {
summary: `docker push failed for ${artifact.image}`,
next: "Verify the D601 local/internal registry is reachable from the Docker daemon and rerun --publish."
}),
logTail: tailText(`${result.stdout}\n${result.stderr}`)
logTail: tailText(`${result.stdout}\n${result.stderr}`),
publishDurationMs: result.durationMs
};
}
@@ -1191,7 +1217,8 @@ async function publishService(artifact) {
summary: `docker push completed for ${artifact.image} but did not return an immutable digest`,
next: "Verify registry digest reporting, then rerun --publish; do not update the catalog with a digest until a sha256 registry digest is observed."
}),
pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200)
pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200),
publishDurationMs: result.durationMs
};
}
@@ -1200,7 +1227,8 @@ async function publishService(artifact) {
status: "published",
digest,
repositoryDigest: `${repositoryFromImageRef(artifact.image)}@${digest}`,
pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200)
pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200),
publishDurationMs: result.durationMs
};
}
@@ -1288,13 +1316,34 @@ function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlo
repositoryDigest: artifact.repositoryDigest ?? null,
digestStatus: /^sha256:[a-f0-9]{64}$/u.test(digest) ? "real" : "placeholder",
status: artifact.status,
dockerBuildDurationMs: artifact.dockerBuildDurationMs ?? null,
cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null,
publishDurationMs: artifact.publishDurationMs ?? null,
notPublishedReason: artifactNotPublishedReason(artifact, mode, fatalBlocked)
};
})
};
}
function createReport({ args, repo, commitId, shortCommit, mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities }) {
function sumDurations(artifacts, field) {
return artifacts.reduce((sum, artifact) => sum + (Number.isFinite(artifact[field]) ? artifact[field] : 0), 0);
}
async function mapWithConcurrency(items, concurrency, worker) {
const results = new Array(items.length);
let nextIndex = 0;
const workerCount = Math.min(Math.max(concurrency, 1), items.length);
await Promise.all(Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await worker(items[index], index);
}
}));
return results;
}
function createReport({ args, repo, commitId, shortCommit, mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer }) {
const status = reportStatus(mode, artifacts, blockers);
const fatalBlocked = hasFatalBlocker(blockers);
const requiredArtifacts = artifacts.filter((artifact) => artifact.artifactRequired);
@@ -1331,6 +1380,8 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
"node --check scripts/src/registry-capabilities.mjs",
"node --check scripts/preflight-dev-base-image.mjs",
"node --check scripts/src/dev-base-image-preflight.mjs",
"node --check scripts/dev-runtime-base-image.mjs",
"node --test scripts/refresh-artifact-catalog.test.mjs",
"node scripts/preflight-dev-base-image.mjs",
"node scripts/dev-artifact-publish.mjs --preflight --no-report",
"node --check scripts/validate-dev-gate-report.mjs",
@@ -1377,8 +1428,12 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
repo,
sourceCommitId: commitId,
registryPrefix: args.registryPrefix,
producer,
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
baseImage: args.baseImage ?? null,
selectedServiceIds: args.services,
quietBuild: args.quietBuild,
concurrency: args.concurrency,
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
environment: ENVIRONMENT_DEV,
buildCreatedAt: artifacts.find((artifact) => artifact.buildCreatedAt)?.buildCreatedAt ?? null,
@@ -1389,6 +1444,11 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
disabledServiceCount: serviceInventory.disabledServiceCount,
builtCount,
publishedCount,
timings: {
dockerBuildDurationMs: sumDurations(artifacts, "dockerBuildDurationMs"),
cloudWebBuildDurationMs: sumDurations(artifacts, "cloudWebBuildDurationMs"),
publishDurationMs: sumDurations(artifacts, "publishDurationMs")
},
generatedAt: new Date().toISOString(),
services: artifacts.map((artifact) => ({
serviceId: artifact.serviceId,
@@ -1408,7 +1468,13 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
artifactRequired: artifact.artifactRequired,
artifactScope: artifact.artifactScope,
notPublishedReason: artifactNotPublishedReason(artifact, mode, fatalBlocked),
localImageId: artifact.localImageId
localImageId: artifact.localImageId,
dockerBuildDurationMs: artifact.dockerBuildDurationMs ?? null,
cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null,
publishDurationMs: artifact.publishDurationMs ?? null,
dockerBuildLogTail: artifact.dockerBuildLogTail ?? null,
logTail: artifact.logTail ?? null,
pushLogTail: artifact.pushLogTail ?? null
})),
blockers
},
@@ -1429,7 +1495,7 @@ async function main() {
return;
}
if (args.mode === "publish") {
const transactionGuard = requireDevCdTransactionForSideEffect({
const transactionGuard = requireDevArtifactSideEffectEnv({
script: "scripts/dev-artifact-publish.mjs",
mode: "--publish"
});
@@ -1461,6 +1527,7 @@ async function main() {
const shortCommit = commitId.slice(0, 7);
const buildCreatedAt = new Date().toISOString();
const repo = repoLabelFromRemote(remoteUrl);
const producer = devArtifactProducer(process.env);
const services = await resolveServices(args.services, catalog);
let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities });
@@ -1478,12 +1545,18 @@ async function main() {
});
} else if (args.mode === "build" || args.mode === "publish") {
const artifactByServiceId = new Map(artifacts.map((artifact) => [artifact.serviceId, artifact]));
for (const service of services.filter((item) => item.artifactRequired)) {
await mapWithConcurrency(services.filter((item) => item.artifactRequired), args.concurrency, async (service) => {
emit("build_start", { serviceId: service.serviceId, image: imageRef(args.registryPrefix, service.serviceId, shortCommit) });
const artifact = await buildService({ args, repo, commitId, shortCommit, service, buildCreatedAt });
artifactByServiceId.set(service.serviceId, artifact);
if (artifact.blocker) blockers.push(artifact.blocker);
emit("build_done", { serviceId: service.serviceId, status: artifact.status, image: artifact.image });
emit("build_done", {
serviceId: service.serviceId,
status: artifact.status,
image: artifact.image,
dockerBuildDurationMs: artifact.dockerBuildDurationMs ?? null,
cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null
});
if (args.mode === "publish" && artifact.status === "built") {
emit("publish_start", { serviceId: service.serviceId, image: artifact.image });
@@ -1494,14 +1567,15 @@ async function main() {
serviceId: service.serviceId,
status: published.status,
image: published.image,
digest: published.digest
digest: published.digest,
publishDurationMs: published.publishDurationMs ?? null
});
}
}
});
artifacts = services.map((service) => artifactByServiceId.get(service.serviceId));
}
const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities });
const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer });
if (args.emitReport) {
await writeReport(args.reportPath, report);
}
@@ -1514,6 +1588,9 @@ async function main() {
registryPrefix: args.registryPrefix,
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
selectedServiceIds: report.artifactPublish.selectedServiceIds,
concurrency: report.artifactPublish.concurrency,
timings: report.artifactPublish.timings,
publishPlan: report.artifactPublish.publishPlan,
services: report.artifactPublish.services.map((service) => ({
serviceId: service.serviceId,
@@ -1523,6 +1600,11 @@ async function main() {
buildCreatedAt: service.buildCreatedAt,
digest: service.digest,
distFreshness: service.distFreshness,
dockerBuildDurationMs: service.dockerBuildDurationMs,
cloudWebBuildDurationMs: service.cloudWebBuildDurationMs,
publishDurationMs: service.publishDurationMs,
logTail: service.logTail,
pushLogTail: service.pushLogTail,
notPublishedReason: service.notPublishedReason
})),
blockers
+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
runDevBaseImagePreflight,
summarizeForRuntimeBase
} from "./src/dev-base-image-preflight.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultRegistryPrefix =
process.env.HWLAB_DEV_REGISTRY_PREFIX || process.env.HWLAB_DEV_REGISTRY || "127.0.0.1:5000/hwlab";
const defaultBaseImage = process.env.HWLAB_DEV_RUNTIME_BASE_PARENT || process.env.HWLAB_DEV_BASE_IMAGE || null;
function parseArgs(argv) {
const args = {
registryPrefix: defaultRegistryPrefix,
baseImage: defaultBaseImage,
tag: null,
build: true,
push: false,
help: false
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--registry-prefix") args.registryPrefix = readOption(argv, ++index, arg);
else if (arg === "--base-image") args.baseImage = readOption(argv, ++index, arg);
else if (arg === "--tag") args.tag = readOption(argv, ++index, arg);
else if (arg === "--dry-run" || arg === "--plan") args.build = false;
else if (arg === "--push") args.push = true;
else if (arg === "--help" || arg === "-h") args.help = true;
else throw new Error(`unknown argument ${arg}`);
}
return args;
}
function readOption(argv, index, name) {
const value = argv[index];
if (!value || value.startsWith("--")) {
throw new Error(`${name} requires a value`);
}
return value;
}
function usage() {
return [
"usage: node scripts/dev-runtime-base-image.mjs [--dry-run] [--push] [--base-image IMAGE] [--tag IMAGE]",
"",
"Build an optional D601-local HWLAB Node runtime base image with package dependencies preinstalled.",
"",
"options:",
" --registry-prefix PREFIX default: 127.0.0.1:5000/hwlab",
" --base-image IMAGE approved local parent image; default resolved by DEV base preflight",
" --tag IMAGE output image tag; default: <registry>/hwlab-node-runtime-base:deps-<hash>",
" --dry-run print the build plan without building",
" --push push the resulting base image to the local/internal registry"
].join("\n");
}
function normalizeRegistryPrefix(prefix) {
const normalized = prefix.replace(/\/+$/u, "");
const [hostPort, ...pathParts] = normalized.split("/");
const host = hostPort.split(":")[0].toLowerCase();
assert.ok(hostPort && pathParts.length > 0, "registry prefix must include host and namespace");
assert.ok(!/prod|production/iu.test(normalized), "registry prefix must not include prod");
assert.ok(
host === "localhost" ||
host === "127.0.0.1" ||
/^10\./u.test(host) ||
/^192\.168\./u.test(host) ||
/^172\.(1[6-9]|2\d|3[0-1])\./u.test(host) ||
host.endsWith(".local") ||
host.endsWith(".svc") ||
host.endsWith(".cluster.local"),
"registry prefix must target localhost or a private/internal host"
);
return normalized;
}
async function fileOrEmpty(relativePath) {
try {
return await readFile(path.join(repoRoot, relativePath), "utf8");
} catch (error) {
if (error?.code === "ENOENT") return "";
throw error;
}
}
async function dependencyHash() {
const packageJson = await fileOrEmpty("package.json");
const packageLockJson = await fileOrEmpty("package-lock.json");
assert.ok(packageJson.trim(), "package.json is required");
const hash = createHash("sha256");
hash.update("package.json\0");
hash.update(packageJson);
hash.update("\0package-lock.json\0");
hash.update(packageLockJson);
return hash.digest("hex");
}
function dockerfile(parentImage, depsHash) {
return [
`FROM ${parentImage}`,
"WORKDIR /opt/hwlab-node-runtime-base",
`LABEL hwlab.pikastech.local/runtime-base-deps-hash=${depsHash}`,
"COPY package.json ./package.json",
"COPY package-lock.json* ./",
"RUN if [ -f package-lock.json ]; then npm ci --omit=dev --ignore-scripts; else npm install --omit=dev --ignore-scripts; fi",
""
].join("\n");
}
function commandLine(command, args) {
return [command, ...args].map((part) => (/\s/u.test(part) ? JSON.stringify(part) : part)).join(" ");
}
function runWithInput(command, args, input) {
const startedAt = Date.now();
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd: repoRoot,
env: process.env,
stdio: ["pipe", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
child.stdin.end(input);
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
resolve({
command: commandLine(command, args),
code: 127,
stdout,
stderr: error.message,
durationMs: Date.now() - startedAt
});
});
child.on("close", (code) => {
resolve({
command: commandLine(command, args),
code: code ?? 1,
stdout,
stderr,
durationMs: Date.now() - startedAt
});
});
});
}
function run(command, args) {
const startedAt = Date.now();
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd: repoRoot,
env: process.env,
stdio: ["ignore", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
resolve({
command: commandLine(command, args),
code: 127,
stdout,
stderr: error.message,
durationMs: Date.now() - startedAt
});
});
child.on("close", (code) => {
resolve({
command: commandLine(command, args),
code: code ?? 1,
stdout,
stderr,
durationMs: Date.now() - startedAt
});
});
});
}
function tailText(value, maxLength = 2500) {
const text = value.trim();
if (text.length <= maxLength) return text;
return text.slice(text.length - maxLength);
}
function inspectDigest(pushOutput) {
const matches = [...pushOutput.matchAll(/digest:\s+(sha256:[a-f0-9]{64})/giu)];
return matches.length ? matches[matches.length - 1][1] : null;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
return;
}
args.registryPrefix = normalizeRegistryPrefix(args.registryPrefix);
const depsHash = await dependencyHash();
const image = args.tag ?? `${args.registryPrefix}/hwlab-node-runtime-base:deps-${depsHash.slice(0, 12)}`;
const baseImagePreflight = await runDevBaseImagePreflight({
env: args.baseImage ? { ...process.env, HWLAB_DEV_BASE_IMAGE: args.baseImage } : process.env
});
const parentImage = baseImagePreflight.localTag;
const plan = {
status: args.build ? "planned" : "dry-run",
image,
parentImage,
depsHash,
push: args.push,
baseImagePreflight: summarizeForRuntimeBase(baseImagePreflight)
};
if (!baseImagePreflight.publishUsable) {
console.log(JSON.stringify({
...plan,
status: "blocked",
blockers: baseImagePreflight.blockers,
nextSteps: baseImagePreflight.nextSteps
}, null, 2));
process.exitCode = 2;
return;
}
if (!args.build) {
console.log(JSON.stringify(plan, null, 2));
return;
}
const build = await runWithInput(
"docker",
["build", "--pull=false", "-t", image, "-f", "-", "."],
dockerfile(parentImage, depsHash)
);
if (build.code !== 0) {
console.log(JSON.stringify({
...plan,
status: "build_failed",
command: build.command,
durationMs: build.durationMs,
logTail: tailText(`${build.stdout}\n${build.stderr}`)
}, null, 2));
process.exitCode = 1;
return;
}
const inspect = await run("docker", ["image", "inspect", "--format", "{{.Id}}", image]);
let push = null;
if (args.push) {
const pushResult = await run("docker", ["push", image]);
const digest = pushResult.code === 0 ? inspectDigest(`${pushResult.stdout}\n${pushResult.stderr}`) : null;
push = {
status: pushResult.code === 0 && digest ? "published" : "blocked",
command: pushResult.command,
code: pushResult.code,
durationMs: pushResult.durationMs,
digest,
logTail: tailText(`${pushResult.stdout}\n${pushResult.stderr}`, 1200)
};
}
const status = push ? push.status : "built";
console.log(JSON.stringify({
...plan,
status,
imageId: inspect.code === 0 ? inspect.stdout.trim() : null,
build: {
command: build.command,
durationMs: build.durationMs,
logTail: tailText(`${build.stdout}\n${build.stderr}`, 1200)
},
push
}, null, 2));
process.exitCode = status === "blocked" ? 2 : 0;
}
main().catch((error) => {
console.error(JSON.stringify({
status: "failed",
error: error instanceof Error ? error.message : String(error)
}, null, 2));
process.exitCode = 1;
});
+2 -1
View File
@@ -77,7 +77,8 @@ function usage() {
}
async function readJson(relativePath) {
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
const filePath = path.isAbsolute(relativePath) ? relativePath : path.join(repoRoot, relativePath);
return JSON.parse(await readFile(filePath, "utf8"));
}
async function writeJson(relativePath, value) {
+85
View File
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { promisify } from "node:util";
import { SERVICE_IDS } from "../internal/protocol/index.mjs";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
async function git(args) {
const result = await execFileAsync("git", args, {
cwd: repoRoot,
timeout: 5000,
maxBuffer: 1024 * 1024
});
return result.stdout.trim();
}
function digestFor(index) {
return `sha256:${String(index + 1).padStart(64, "0")}`;
}
test("refresh accepts an absolute publish report path", async () => {
const commitId = await git(["rev-parse", "HEAD"]);
const shortCommitId = await git(["rev-parse", "--short=7", "HEAD"]);
const tempDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-refresh-"));
const reportPath = path.join(tempDir, "dev-artifacts.json");
await writeFile(reportPath, `${JSON.stringify({
reportVersion: "v1",
taskId: "dev-artifact-publish",
commitId: shortCommitId,
artifactPublish: {
status: "published",
mode: "publish",
sourceCommitId: commitId,
registryPrefix: "127.0.0.1:5000/hwlab",
serviceCount: SERVICE_IDS.length,
requiredServiceCount: SERVICE_IDS.length,
disabledServiceCount: 0,
publishedCount: SERVICE_IDS.length,
publishPlan: {
version: "v2",
services: SERVICE_IDS.map((serviceId, index) => ({
serviceId,
required: true,
digest: digestFor(index),
image: `127.0.0.1:5000/hwlab/${serviceId}:${shortCommitId}`,
imageTag: shortCommitId
}))
},
services: SERVICE_IDS.map((serviceId, index) => ({
serviceId,
status: "published",
artifactRequired: true,
image: `127.0.0.1:5000/hwlab/${serviceId}:${shortCommitId}`,
imageTag: shortCommitId,
digest: digestFor(index),
repositoryDigest: `127.0.0.1:5000/hwlab/${serviceId}@${digestFor(index)}`,
buildCreatedAt: "2026-05-24T00:00:00.000Z",
buildSource: `pikasTech/HWLAB@${commitId}`
}))
}
}, null, 2)}\n`);
const result = await execFileAsync(process.execPath, [
"scripts/refresh-artifact-catalog.mjs",
"--target-ref",
"HEAD",
"--publish-report",
reportPath,
"--no-write"
], {
cwd: repoRoot,
timeout: 15000,
maxBuffer: 10 * 1024 * 1024
});
const payload = JSON.parse(result.stdout);
assert.equal(payload.status, "published");
assert.equal(payload.publishedCount, SERVICE_IDS.length);
assert.deepEqual(payload.wrote, []);
});
+18 -2
View File
@@ -21,7 +21,9 @@ const allowedExplicitBaseImages = Object.freeze([
"hwlab-dev-base:*",
"*/hwlab-dev-base:*",
"hwlab-node20-base:*",
"*/hwlab-node20-base:*"
"*/hwlab-node20-base:*",
"hwlab-node-runtime-base:*",
"*/hwlab-node-runtime-base:*"
]);
function baseImageRecommendation(envValue = recommendedNode20Image) {
@@ -131,7 +133,7 @@ function isNode20BaseRef(imageRef) {
function isHwlabDevBaseRef(imageRef) {
const lowerRef = imageRef.toLowerCase();
return /(^|\/)(hwlab-dev-base|hwlab-node20-base)(?=[:@]|$)/u.test(lowerRef);
return /(^|\/)(hwlab-dev-base|hwlab-node20-base|hwlab-node-runtime-base)(?=[:@]|$)/u.test(lowerRef);
}
function isTaggedImage(image) {
@@ -612,3 +614,17 @@ export function formatPreflightResult(result) {
export function exitCodeForPreflight(result) {
return result.publishUsable ? 0 : 2;
}
export function summarizeForRuntimeBase(result) {
return {
status: result.status,
publishUsable: result.publishUsable,
imageSource: result.imageSource,
requestedImage: result.requestedImage,
localTag: result.localTag,
imageId: result.imageId,
blockers: result.blockers,
nextSteps: result.nextSteps,
containerEngine: result.containerEngine
};
}
+12 -6
View File
@@ -513,19 +513,24 @@ async function resolveArtifactBoundary(ctx, control, promotion, deployBefore, ar
const reportCommitMatches = commitMatches(reportSource, promotion.commitId);
const deployCommitMatches = commitMatches(deployBefore.commitId, promotion.commitId);
const isMergeCommit = parents.length >= 2;
const status = (
const desiredStateStatus = (
deployCommitMatches &&
catalogCommitMatches &&
reportCommitMatches &&
(
promotionParentMatches ||
artifactParentMatches ||
commitMatches(mergeCommitId, promotion.commitId)
)
) ? "pass" : "degraded";
const reportStatus = artifactEvidence.report.exists
? (reportCommitMatches ? "matches_promotion" : "stale_or_unrelated")
: "missing";
return {
status,
status: desiredStateStatus,
reportStatus,
reportIsReleaseGate: false,
reportPolicy: "CI artifact reports are audit snapshots; deploy.json, artifact catalog, workloads, and registry manifests are the CD release gate.",
controlCommit: {
commitId: control.commitId,
shortCommitId: control.shortCommitId
@@ -557,6 +562,7 @@ async function resolveArtifactBoundary(ctx, control, promotion, deployBefore, ar
deployCommitMatches,
catalogCommitMatches,
reportCommitMatches,
reportExists: artifactEvidence.report.exists,
desiredStatePaths: artifactDesiredStatePaths
}
};
@@ -2335,7 +2341,7 @@ function buildReport({
"docs/dev-deploy-apply.md",
"docs/artifact-catalog.md"
],
summary: "DEV CD publish/apply/verify side effects are serialized by one Kubernetes Lease and use deploy/deploy.json as the apply desired-state source."
summary: "DEV CD apply/verify side effects are serialized by one Kubernetes Lease and use deploy/deploy.json as the publish-control desired-state source; CI artifact reports are audit snapshots."
},
validationCommands: [
"node --check scripts/dev-cd-apply.mjs",
@@ -2367,8 +2373,8 @@ function buildReport({
"DEV CD apply must reject docker-desktop, desktop-control-plane, 127.0.0.1:11700, bare kubectl ambiguity, and second hwlab-dev control-plane signals before mutation.",
"DEV CD apply must verify required SecretRef existence and key names without reading or printing Secret values.",
"HEAD must match the requested target ref before publish.",
"Lease/hwlab-dev-cd-lock must be acquired before publish/apply.",
"Legacy publish/apply side-effect scripts must run with HWLAB_CD_TRANSACTION_ID.",
"Lease/hwlab-dev-cd-lock must be acquired before DEV apply/verify side effects.",
"Artifact publish must run under CI artifact identity, with HWLAB_CD_TRANSACTION_ID accepted only for the legacy transition path.",
"DEV DB role/database provisioning and runtime migration must run through repo-owned commands before workload apply.",
"deploy/deploy.json, artifact catalog, and workloads must converge before apply.",
"When deploy/deploy.json already points to a published promotion commit, HEAD may be a later control commit and the transaction must consume that promotion without republishing HEAD.",
+55 -5
View File
@@ -587,6 +587,56 @@ test("dev-cd status accepts a published deploy-json promotion under a later cont
assertNoReadOnlySideEffects(commandLog);
});
test("dev-cd status treats stale CI artifact report as audit-only when desired state matches", async () => {
const repoRoot = await makeRepo({ commitId: "abc1234" });
await writeFile(
artifactReportFixturePath,
`${JSON.stringify({
status: "published",
commitId: "9999999",
artifactPublish: {
status: "published",
sourceCommitId: "9999999",
serviceCount: 0,
requiredServiceCount: 0,
publishedCount: 0,
services: []
}
}, null, 2)}\n`
);
const commandLog = [];
let output = "";
const code = await runDevCdApply(["--status", "--kubeconfig", "/etc/rancher/k3s/k3s.yaml"], {
repoRoot,
env: {},
runCommand: makeRunCommand({
commandLog,
targetCommitId: laterControlCommit,
headCommitId: laterControlCommit,
extraGitRefs: {
abc1234: publishedCommit
},
latestArtifactMergeCommit: artifactMergeCommit,
commitParents: {
[artifactMergeCommit]: [publishedCommit, artifactReportCommit],
[artifactReportCommit]: [publishedCommit]
}
}),
httpGetJson: makeHttpGetJson(),
now: () => new Date(iso(100000)),
stdout: { write: (chunk) => { output += chunk; } }
});
const status = JSON.parse(output);
assert.equal(code, 0);
assert.equal(status.status, "pass");
assert.equal(status.target.artifactBoundary.status, "pass");
assert.equal(status.target.artifactBoundary.reportStatus, "stale_or_unrelated");
assert.equal(status.target.artifactBoundary.reportIsReleaseGate, false);
assert.equal(commandLog.some((entry) => entry.args.includes("scripts/dev-artifact-publish.mjs")), false);
assertNoReadOnlySideEffects(commandLog);
});
test("dev-cd status exposes moving origin/main when checkout is behind a deploy-json promotion control ref", async () => {
const repoRoot = await makeRepo({ commitId: "abc1234" });
const commandLog = [];
@@ -603,8 +653,8 @@ test("dev-cd status exposes moving origin/main when checkout is behind a deploy-
},
latestArtifactMergeCommit: artifactMergeCommit,
commitParents: {
[artifactMergeCommit]: [publishedCommit, artifactReportCommit],
[artifactReportCommit]: [publishedCommit]
[artifactMergeCommit]: [laterControlCommit, artifactReportCommit],
[artifactReportCommit]: [laterControlCommit]
}
}),
httpGetJson: makeHttpGetJson(),
@@ -666,7 +716,7 @@ test("apply stops before lock acquisition when checkout is behind deploy-json pr
assert.equal(commandLog.some((entry) => entry.args.includes("scripts/dev-deploy-apply.mjs")), false);
});
test("dev-cd status degrades artifact merge provenance when catalog/report do not match deploy-json promotion", async () => {
test("dev-cd status degrades artifact merge provenance when catalog does not match deploy-json promotion", async () => {
const repoRoot = await makeRepo({ commitId: "abc1234" });
await writeFile(
path.join(repoRoot, "deploy/artifact-catalog.dev.json"),
@@ -751,8 +801,8 @@ test("apply stops before lock acquisition when artifact merge provenance mismatc
},
latestArtifactMergeCommit: artifactMergeCommit,
commitParents: {
[artifactMergeCommit]: [publishedCommit, artifactReportCommit],
[artifactReportCommit]: [publishedCommit]
[artifactMergeCommit]: [laterControlCommit, artifactReportCommit],
[artifactReportCommit]: [laterControlCommit]
}
}),
httpGetJson: makeHttpGetJson(),
+52
View File
@@ -5,11 +5,45 @@ const transactionEnvNames = [
"HWLAB_CD_TRANSACTION_OWNER",
"HWLAB_CD_LOCK_NAME"
];
const ciArtifactEnvNames = [
"HWLAB_CI_ARTIFACT_RUN_ID",
"HWLAB_CI_ARTIFACT_RUN_OWNER"
];
export function hasDevCdTransactionEnv(env = process.env) {
return typeof env.HWLAB_CD_TRANSACTION_ID === "string" && env.HWLAB_CD_TRANSACTION_ID.trim().length > 0;
}
export function hasDevCiArtifactEnv(env = process.env) {
return typeof env.HWLAB_CI_ARTIFACT_RUN_ID === "string" && env.HWLAB_CI_ARTIFACT_RUN_ID.trim().length > 0;
}
export function hasDevArtifactSideEffectEnv(env = process.env) {
return hasDevCdTransactionEnv(env) || hasDevCiArtifactEnv(env);
}
export function devArtifactProducer(env = process.env) {
if (hasDevCiArtifactEnv(env)) {
return {
kind: "ci-artifact",
runId: env.HWLAB_CI_ARTIFACT_RUN_ID,
owner: env.HWLAB_CI_ARTIFACT_RUN_OWNER ?? null
};
}
if (hasDevCdTransactionEnv(env)) {
return {
kind: "cd-transaction-legacy",
runId: env.HWLAB_CD_TRANSACTION_ID,
owner: env.HWLAB_CD_TRANSACTION_OWNER ?? null
};
}
return {
kind: "missing",
runId: null,
owner: null
};
}
export function devCdTransactionGuardFailure({
script,
mode,
@@ -32,7 +66,25 @@ export function devCdTransactionGuardFailure({
};
}
export function devArtifactSideEffectGuardFailure({
script,
mode,
requiredEnv = "HWLAB_CI_ARTIFACT_RUN_ID or HWLAB_CD_TRANSACTION_ID"
}) {
return {
...devCdTransactionGuardFailure({ script, mode, requiredEnv }),
acceptedEnv: [...ciArtifactEnvNames, ...transactionEnvNames],
entrypoint: `HWLAB_CI_ARTIFACT_RUN_ID=<run-id> node scripts/dev-artifact-publish.mjs --publish --report ${tempReportPath("dev-artifacts.json")}`,
summary: `${script} ${mode} is a DEV artifact side-effect step and must run inside a CI artifact run or the legacy DEV CD transaction.`
};
}
export function requireDevCdTransactionForSideEffect({ env = process.env, script, mode }) {
if (hasDevCdTransactionEnv(env)) return null;
return devCdTransactionGuardFailure({ script, mode });
}
export function requireDevArtifactSideEffectEnv({ env = process.env, script, mode }) {
if (hasDevArtifactSideEffectEnv(env)) return null;
return devArtifactSideEffectGuardFailure({ script, mode });
}
+321 -34
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { constants as fsConstants } from "node:fs";
import { access, readFile, writeFile } from "node:fs/promises";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { request as httpRequest } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -88,6 +88,8 @@ const forbiddenActions = [
"force-push"
];
const runtimeIdentityEnvNames = ["HWLAB_COMMIT_ID", "HWLAB_IMAGE", "HWLAB_IMAGE_TAG"];
const digestPattern = /^sha256:[a-f0-9]{64}$/u;
const defaultCdConcurrency = parseBoundedInteger(process.env.HWLAB_DEV_CD_CONCURRENCY, 4, 1, 8);
export function parseArgs(argv) {
const flags = new Set();
@@ -95,6 +97,7 @@ export function parseArgs(argv) {
let kubeconfig = null;
let kubeconfigSpecified = false;
let reportPath = defaultReportPath;
let rolloutConcurrency = defaultCdConcurrency;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
@@ -141,6 +144,22 @@ export function parseArgs(argv) {
}
continue;
}
if (arg === "--rollout-concurrency") {
flags.add("--rollout-concurrency");
const next = argv[index + 1];
if (typeof next !== "string" || next.startsWith("--")) {
errors.push("--rollout-concurrency requires a numeric value");
} else {
rolloutConcurrency = parseBoundedInteger(next, defaultCdConcurrency, 1, 8);
index += 1;
}
continue;
}
if (arg.startsWith("--rollout-concurrency=")) {
flags.add("--rollout-concurrency");
rolloutConcurrency = parseBoundedInteger(arg.slice("--rollout-concurrency=".length), defaultCdConcurrency, 1, 8);
continue;
}
flags.add(arg);
}
@@ -151,6 +170,7 @@ export function parseArgs(argv) {
skipLiveProbe: flags.has("--skip-live-probe"),
writeReport: flags.has("--report-output"),
reportPath,
rolloutConcurrency,
kubeconfig,
kubeconfigSpecified,
errors,
@@ -158,6 +178,12 @@ export function parseArgs(argv) {
};
}
function parseBoundedInteger(value, fallback, min, max) {
const parsed = Number.parseInt(String(value ?? ""), 10);
if (!Number.isInteger(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
function addBlocker(blockers, type, scope, summary) {
const key = `${type}::${scope}`;
if (!blockers.some((blocker) => `${blocker.type}::${blocker.scope}` === key)) {
@@ -169,6 +195,21 @@ function oneLine(value) {
return String(value).replace(/\s+/g, " ").trim();
}
async function mapWithConcurrency(items, concurrency, worker) {
if (items.length === 0) return [];
const results = new Array(items.length);
let nextIndex = 0;
const workerCount = Math.min(Math.max(concurrency, 1), items.length);
await Promise.all(Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await worker(items[index], index);
}
}));
return results;
}
async function readJson(relativePath, blockers) {
try {
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
@@ -675,6 +716,146 @@ function parseImageTag(image) {
return image.slice(colonIndex + 1);
}
function parseTaggedImageReference(image) {
if (typeof image !== "string" || image.includes("@")) return null;
const firstSlash = image.indexOf("/");
const lastSlash = image.lastIndexOf("/");
const colonIndex = image.lastIndexOf(":");
if (firstSlash <= 0 || colonIndex <= lastSlash) return null;
return {
image,
registry: image.slice(0, firstSlash),
repository: image.slice(firstSlash + 1, colonIndex),
tag: image.slice(colonIndex + 1)
};
}
function registryManifestUrlForImage(image) {
const parsed = parseTaggedImageReference(image);
if (!parsed) return null;
const repositoryPath = parsed.repository.split("/").map((part) => encodeURIComponent(part)).join("/");
return {
...parsed,
url: `http://${parsed.registry}/v2/${repositoryPath}/manifests/${encodeURIComponent(parsed.tag)}`
};
}
function registryAcceptHeader() {
return [
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.v1+json"
].join(", ");
}
function httpRequestText(url, { method = "GET", headers = {}, timeoutMs = 15000 } = {}) {
return new Promise((resolve, reject) => {
const request = httpRequest(url, { method, headers, timeout: timeoutMs }, (response) => {
response.setEncoding("utf8");
let body = "";
response.on("data", (chunk) => {
body += chunk;
if (body.length > 4096) {
request.destroy(new Error("registry manifest response exceeded 4096 bytes during CD verification"));
}
});
response.on("end", () => resolve({
statusCode: response.statusCode ?? 0,
headers: response.headers,
body
}));
});
request.on("timeout", () => {
request.destroy(new Error(`timeout after ${timeoutMs}ms`));
});
request.on("error", reject);
request.end();
});
}
async function verifyCatalogRegistryManifest(service, blockers) {
const image = service.image;
const expectedDigest = service.digest;
const parsed = registryManifestUrlForImage(image);
const base = {
serviceId: service.serviceId,
image,
imageTag: service.imageTag ?? parseImageTag(image),
expectedDigest,
url: parsed?.url ?? null
};
if (!parsed) {
const reason = `${service.serviceId} image is not a tagged registry image`;
addBlocker(blockers, "contract_blocker", `registry-manifest-${service.serviceId}`, reason);
return { ...base, status: "blocked", reason };
}
if (!digestPattern.test(expectedDigest ?? "")) {
const reason = `${service.serviceId} catalog digest is not an immutable sha256 digest`;
addBlocker(blockers, "contract_blocker", `registry-manifest-${service.serviceId}`, reason);
return { ...base, status: "blocked", reason };
}
try {
const response = await httpRequestText(parsed.url, {
method: "HEAD",
headers: {
Accept: registryAcceptHeader()
},
timeoutMs: 15000
});
const observedDigest = String(response.headers["docker-content-digest"] ?? "").trim();
const okStatus = response.statusCode >= 200 && response.statusCode < 300;
const digestMatches = observedDigest === expectedDigest;
if (!okStatus || !digestMatches) {
const reason = !okStatus
? `${service.serviceId} registry manifest returned HTTP ${response.statusCode}`
: `${service.serviceId} registry digest mismatch: expected ${expectedDigest}, observed ${observedDigest || "missing"}`;
addBlocker(blockers, "environment_blocker", `registry-manifest-${service.serviceId}`, reason);
return {
...base,
status: "blocked",
reason,
httpStatus: response.statusCode,
observedDigest,
digestMatches
};
}
return {
...base,
status: "pass",
httpStatus: response.statusCode,
observedDigest,
digestMatches: true
};
} catch (error) {
const reason = `${service.serviceId} registry manifest read failed: ${oneLine(error.message)}`;
addBlocker(blockers, "environment_blocker", `registry-manifest-${service.serviceId}`, reason);
return {
...base,
status: "blocked",
reason
};
}
}
async function verifyCatalogRegistryManifests(catalog, blockers, concurrency = defaultCdConcurrency) {
const services = (catalog?.services ?? []).filter((service) => service.artifactRequired !== false);
const results = await mapWithConcurrency(services, concurrency, async (service) =>
verifyCatalogRegistryManifest(service, blockers)
);
return {
status: results.every((result) => result.status === "pass") ? "pass" : "blocked",
source: "registry-manifest",
releaseGate: true,
serviceCount: results.length,
concurrency,
results
};
}
function expectedRuntimeIdentityEnv(artifact) {
return {
HWLAB_COMMIT_ID: artifact.sourceCommitId,
@@ -1675,9 +1856,9 @@ async function buildDevLegacySimulatorDeploymentCleanupPlan(kubectl, workloads,
return cleanups;
}
async function executeDevTemplateJobReplacements(kubectl, replacements, blockers) {
async function executeDevTemplateJobReplacements(kubectl, replacements, blockers, concurrency = defaultCdConcurrency) {
const planned = replacements.filter((replacement) => replacement.replace && replacement.result === "planned");
for (const replacement of planned) {
await mapWithConcurrency(planned, concurrency, async (replacement) => {
const commandArgs = [
"-n",
replacement.namespace,
@@ -1694,17 +1875,17 @@ async function executeDevTemplateJobReplacements(kubectl, replacements, blockers
replacement.result = "delete_failed";
replacement.reason = `Failed to delete live suspended template Job before apply: ${oneLine(commandOutput(result))}`;
addBlocker(blockers, "environment_blocker", `template-job-replace-${replacement.jobName}`, replacement.reason);
continue;
return;
}
replacement.result = "deleted_pending_recreate";
replacement.reason = "Live suspended template Job was deleted; kubectl apply must recreate it from the desired manifest.";
}
});
return planned.length;
}
async function executeDevLegacySimulatorDeploymentCleanups(kubectl, cleanups, blockers) {
async function executeDevLegacySimulatorDeploymentCleanups(kubectl, cleanups, blockers, concurrency = defaultCdConcurrency) {
const planned = cleanups.filter((cleanup) => cleanup.cleanup && cleanup.result === "planned");
for (const cleanup of planned) {
await mapWithConcurrency(planned, concurrency, async (cleanup) => {
const commandArgs = [
"-n",
cleanup.namespace,
@@ -1721,14 +1902,107 @@ async function executeDevLegacySimulatorDeploymentCleanups(kubectl, cleanups, bl
cleanup.result = "delete_failed";
cleanup.reason = `Failed to delete stale legacy simulator Deployment before apply: ${oneLine(commandOutput(result))}`;
addBlocker(blockers, "environment_blocker", `legacy-simulator-deployment-cleanup-${cleanup.deploymentName}`, cleanup.reason);
continue;
return;
}
cleanup.result = "deleted_pending_apply";
cleanup.reason = "Live stale legacy simulator Deployment was deleted; kubectl apply must leave the desired indexed StatefulSet in place.";
}
});
return planned.length;
}
function rolloutApiName(kind) {
if (kind === "Deployment") return "deployment";
if (kind === "StatefulSet") return "statefulset";
return null;
}
function rolloutTargetsFromWorkloads(workloads) {
return listItems(workloads)
.map((item) => {
const apiName = rolloutApiName(item?.kind);
if (!apiName) return null;
return {
kind: item.kind,
apiName,
name: item?.metadata?.name ?? "unknown",
namespace: item?.metadata?.namespace ?? namespace,
replicas: replicaPlan(item),
serviceIds: uniqueStrings(containersFor(item).map((container) => serviceIdFor(item, container)))
};
})
.filter(Boolean);
}
async function verifyRolloutTarget(kubectl, target, blockers) {
const commandArgs = [
"-n",
target.namespace,
"rollout",
"status",
`${target.apiName}/${target.name}`,
"--timeout=180s"
];
const base = {
...target,
command: kubectlCommand(kubectl, commandArgs)
};
if (kubectl.status !== "ready") {
addBlocker(blockers, "environment_blocker", `rollout-status-${target.name}`, kubectl.reason);
return {
...base,
status: "blocked",
reason: kubectl.reason
};
}
const result = await kubectlResult(kubectl, commandArgs, 190000);
if (!result.ok) {
const reason = oneLine(commandOutput(result));
addBlocker(blockers, "runtime_blocker", `rollout-status-${target.name}`, reason);
return {
...base,
status: "blocked",
reason,
stdout: result.redactedStdout ?? result.stdout,
stderr: result.redactedStderr ?? result.stderr
};
}
return {
...base,
status: "pass",
stdout: result.redactedStdout ?? result.stdout,
stderr: result.redactedStderr ?? result.stderr
};
}
async function verifyRolloutsAfterApply(args, kubectl, workloads, blockers, applyStep) {
const targets = rolloutTargetsFromWorkloads(workloads);
if (!args.apply) {
return {
status: "not_run",
reason: "dry-run mode",
concurrency: args.rolloutConcurrency,
targets
};
}
if (applyStep.status !== "pass") {
return {
status: "not_run",
reason: "apply did not complete",
concurrency: args.rolloutConcurrency,
targets
};
}
const results = await mapWithConcurrency(targets, args.rolloutConcurrency, async (target) =>
verifyRolloutTarget(kubectl, target, blockers)
);
return {
status: results.every((result) => result.status === "pass") ? "pass" : "blocked",
concurrency: args.rolloutConcurrency,
targetCount: targets.length,
results
};
}
function isExpectedTemplateJobImmutableFailure(result, replacementsNeeded) {
const plannedNames = replacementsNeeded.map((replacement) => replacement.jobName);
if (result.ok || plannedNames.length === 0) return false;
@@ -1758,8 +2032,8 @@ async function runApplyStep(args, kubectl, blockers, templateJobReplacements, le
const replacementsNeeded = templateJobReplacements.filter((replacement) => replacement.replace);
if (args.apply) {
const replaceCount = await executeDevTemplateJobReplacements(kubectl, templateJobReplacements, blockers);
const cleanupCount = await executeDevLegacySimulatorDeploymentCleanups(kubectl, legacySimulatorDeploymentCleanups, blockers);
const replaceCount = await executeDevTemplateJobReplacements(kubectl, templateJobReplacements, blockers, args.rolloutConcurrency);
const cleanupCount = await executeDevLegacySimulatorDeploymentCleanups(kubectl, legacySimulatorDeploymentCleanups, blockers, args.rolloutConcurrency);
mutationAttempted = replaceCount > 0 || cleanupCount > 0;
if (blockers.length > 0) {
return {
@@ -1858,6 +2132,7 @@ export async function runDevDeployApply(argv, io = {}) {
const commitId = resolveApplySourceCommit(deploy, catalog, gitHeadCommitId);
const artifactEvidence = validateDeployAndCatalog(deploy, catalog, commitId, blockers);
const registryManifests = await verifyCatalogRegistryManifests(catalog, blockers, args.rolloutConcurrency);
const k8sManifest = validateK8s(devKustomization, namespaceDoc, workloads, services, healthContract, deploy, catalog, blockers);
const cloudApiDb = await checkCloudApiDb(deploy, workloads, services, blockers);
const codeAgentProviderDesiredState = inspectCodeAgentProviderDesiredState(deploy, workloads);
@@ -1886,33 +2161,41 @@ export async function runDevDeployApply(argv, io = {}) {
templateJobReplacements,
legacySimulatorDeploymentCleanups
);
const cloudWebRolloutAfterApply = args.apply && applyStep.status === "pass"
? await observeDeploymentRollout(kubectl, deploy, catalog, "hwlab-cloud-web", blockers, {
let rolloutStatusAfterApply;
let cloudWebRolloutAfterApply;
let codeAgentProviderLiveAfterApply;
if (args.apply && applyStep.status === "pass") {
[rolloutStatusAfterApply, cloudWebRolloutAfterApply, codeAgentProviderLiveAfterApply] = await Promise.all([
verifyRolloutsAfterApply(args, kubectl, workloads, blockers, applyStep),
observeDeploymentRollout(kubectl, deploy, catalog, "hwlab-cloud-web", blockers, {
observationPhase: "after_apply",
blockRuntimeEnvDrift: true
})
: skippedDeploymentRolloutObservation(
kubectl,
deploy,
catalog,
"hwlab-cloud-web",
"after_apply",
args.apply ? "apply did not complete successfully; post-apply rollout env verification was not run" : "dry-run mode; no post-apply rollout env verification"
);
const cloudWebRollout = args.apply ? cloudWebRolloutAfterApply : cloudWebRolloutBeforeApply;
const codeAgentProviderLiveAfterApply = args.apply && applyStep.status === "pass"
? await observeCodeAgentProviderLiveDeployment(kubectl, blockers, {
}),
observeCodeAgentProviderLiveDeployment(kubectl, blockers, {
phase: "after_apply",
blockOnMismatch: true
})
: {
phase: "after_apply",
status: "not_run",
reason: args.apply ? "apply did not complete" : "dry-run mode",
secretValuesRead: false,
kubernetesSecretDataRead: false,
valuesRedacted: true
};
]);
} else {
rolloutStatusAfterApply = await verifyRolloutsAfterApply(args, kubectl, workloads, blockers, applyStep);
cloudWebRolloutAfterApply = skippedDeploymentRolloutObservation(
kubectl,
deploy,
catalog,
"hwlab-cloud-web",
"after_apply",
args.apply ? "apply did not complete successfully; post-apply rollout env verification was not run" : "dry-run mode; no post-apply rollout env verification"
);
codeAgentProviderLiveAfterApply = {
phase: "after_apply",
status: "not_run",
reason: args.apply ? "apply did not complete" : "dry-run mode",
secretValuesRead: false,
kubernetesSecretDataRead: false,
valuesRedacted: true
};
}
const cloudWebRollout = args.apply ? cloudWebRolloutAfterApply : cloudWebRolloutBeforeApply;
const status = blockers.length > 0 ? "blocked" : "pass";
const artifactPlan = buildArtifactPlan(deploy, catalog, commitId, artifactEvidence);
const workloadPlan = buildWorkloadPlan(workloads);
@@ -1964,6 +2247,7 @@ export async function runDevDeployApply(argv, io = {}) {
evidence: [
`artifact services checked: ${artifactEvidence.length}`,
`expected artifact commit: ${artifactPlan.expectedArtifactCommit}`,
`registry manifests: ${registryManifests.status} (${registryManifests.serviceCount} services)`,
`namespace: ${namespace}`,
`workloads planned: ${workloadPlan.length}`,
`kubectl executor: ${kubectl.executor ?? "missing"}`,
@@ -1972,6 +2256,7 @@ export async function runDevDeployApply(argv, io = {}) {
`live health: ${liveProbe.status}`,
`code agent provider desired-state: ${codeAgentProviderDesiredState.status}`,
`code agent provider live env before apply: ${codeAgentProviderLiveBeforeApply.status}`,
`rollout status concurrency: ${args.rolloutConcurrency}`,
`template job replacements: ${templateJobReplacements.filter((replacement) => replacement.replace).length}`,
`legacy simulator deployment cleanups: ${legacySimulatorDeploymentCleanups.filter((cleanup) => cleanup.cleanup).length}`
],
@@ -1980,7 +2265,7 @@ export async function runDevDeployApply(argv, io = {}) {
devPreconditions: {
status,
requirements: [
"DEV artifact catalog must contain CI publish evidence and registry digests",
"DEV artifact catalog must contain CI publish expectations and registry digests; CD must verify those tag/digest pairs directly against registry manifests",
"kubectl must be available for D601 hwlab-dev and must not target PROD",
"hwlab-cloud-api /health/live must report serviceId, dev environment, and DB env readiness without exposing secret values",
"hwlab-cloud-api must declare and preserve OPENAI_API_KEY from hwlab-code-agent-provider/openai-api-key plus the DEV Code Agent egress proxy env without reading Secret values",
@@ -2009,6 +2294,7 @@ export async function runDevDeployApply(argv, io = {}) {
cloudWebRollout,
cloudWebRolloutBeforeApply,
cloudWebRolloutAfterApply,
rolloutStatusAfterApply,
codeAgentProvider: {
desiredState: codeAgentProviderDesiredState,
liveBeforeApply: codeAgentProviderLiveBeforeApply,
@@ -2024,6 +2310,7 @@ export async function runDevDeployApply(argv, io = {}) {
rollbackHint: buildRollbackHint(kubectl, workloads),
remainingBlockers,
artifactEvidence,
registryManifests,
cloudApiDb,
k8sManifest,
clusterObservation,
+28
View File
@@ -15,8 +15,10 @@ import {
resolveDevKubeconfigSelection
} from "./dev-deploy-apply.mjs";
import {
devArtifactProducer,
devCdTransactionGuardFailure,
hasDevCdTransactionEnv,
requireDevArtifactSideEffectEnv,
requireDevCdTransactionForSideEffect
} from "./dev-cd-transaction-guard.mjs";
@@ -141,6 +143,32 @@ test("DEV CD transaction guard rejects direct legacy side-effect calls", () => {
);
});
test("DEV artifact publish guard accepts CI artifact identity without CD lock", () => {
const ciEnv = {
HWLAB_CI_ARTIFACT_RUN_ID: "ci-artifact-123",
HWLAB_CI_ARTIFACT_RUN_OWNER: "tekton"
};
assert.equal(requireDevArtifactSideEffectEnv({
env: ciEnv,
script: "scripts/dev-artifact-publish.mjs",
mode: "--publish"
}), null);
assert.deepEqual(devArtifactProducer(ciEnv), {
kind: "ci-artifact",
runId: "ci-artifact-123",
owner: "tekton"
});
const failure = requireDevArtifactSideEffectEnv({
env: {},
script: "scripts/dev-artifact-publish.mjs",
mode: "--publish"
});
assert.equal(failure.error, "cd-transaction-required");
assert.match(failure.requiredEnv, /HWLAB_CI_ARTIFACT_RUN_ID/u);
assert.match(failure.entrypoint, /HWLAB_CI_ARTIFACT_RUN_ID/u);
});
test("matching allowlisted suspended template Job image does not replace", () => {
const image = "127.0.0.1:5000/hwlab/hwlab-cli:73b379f";
const decision = decideDevTemplateJobReplacement({