refactor: remove legacy dev cd scripts

This commit is contained in:
Codex
2026-05-26 05:31:57 +08:00
parent f3191736fa
commit e6ac2898fe
32 changed files with 56 additions and 11242 deletions
+7 -7
View File
@@ -14,11 +14,11 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥
- HWLAB #7、用户反馈、长期看板和指挥简报的 GitHub issue 正文写入必须走 UniDesk CLI`cd /root/unidesk && bun scripts/cli.ts gh ...`;禁止直接用原生 `gh issue edit/create/comment` 写这些 issue。事故和工具补强需求见 [pikasTech/unidesk#142](https://github.com/pikasTech/unidesk/issues/142)。
- 在 UniDesk CLI 局部替换、写前备份和写后 hash 验证能力完成前,不要对 #7 做无 guard 的整篇 body replace;必须先保留 before body、确认维护纪律 heading 仍存在,再写入。
## P0 DEV CD Promotion 顺序
## P0 Legacy CD 删除纪律
- `ci-publish` 只证明镜像已经为 publish report 的 `artifactPublish.sourceCommitId` 构建并推送;它不会改变 rollout 目标。禁止在 `ci-publish` 成功后直接把 `dev-cd-apply` 的旧 desired-state 输出当作新版本上线成功
- 正式 DEV CD 前必须用刚成功的 publish report 刷新三份 repo desired-state`node scripts/refresh-artifact-catalog.mjs --target-ref <published-sourceCommitId> --publish-report <ci-publish-report.json>`,再执行 `node scripts/deploy-desired-state-plan.mjs --promotion-commit <published-sourceCommitId> --check`,确认 `deploy/deploy.json``deploy/artifact-catalog.dev.json``deploy/k8s/base/workloads.yaml` 全部指向同一个已发布 commit
- 只有刷新后的 desired-state 已提交/推送,并且 CD 报告与 `16666/16667` live health 都观测到该短 commit,才能声明 DEV 上线完成;如果 CD report 里 `commitId` 仍是旧值,必须先修 desired-state,不要重复跑 CD。长期细节见 [docs/reference/deployment-publish.md](docs/reference/deployment-publish.md)
- 旧 D601 `ci-publish``dev-cd-apply``dev-deploy-apply``dev-artifact-publish` 脚本入口已经从当前发布面删除;不要恢复这些文件、CLI 子命令或文档入口
- 当前发布只走 G14 k3s Tekton + `G14-gitops` + Argo CD;镜像构建 helper 是 `scripts/g14-artifact-publish.mjs`desired state 由 `scripts/g14-gitops-render.mjs` 生成,细则见 [docs/reference/g14-gitops-cicd.md](docs/reference/g14-gitops-cicd.md)
- 发现旧脚本、旧文档或旧 `hwlab-cli cicd` 再次进入 G14 Pipeline、AGENTS 或长期参考时,优先删除旧入口并把调用方改到 G14 GitOps 路径,不再做兼容保留
## 工作区
@@ -46,7 +46,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥
- 文档治理与 docs-spec 本地权威:[docs/reference/documentation-governance.md](docs/reference/documentation-governance.md)
- 架构和 M3 主线:[docs/reference/architecture.md](docs/reference/architecture.md)
- DEV 运行态、端口、k3s 和 DB DNS 边界:[docs/reference/dev-runtime-boundary.md](docs/reference/dev-runtime-boundary.md)
- 部署正规化、`deploy.json` DEV CD 路径、SecretRef preflight、runner/host 边界和镜像发布:[docs/reference/deployment-publish.md](docs/reference/deployment-publish.md)
- G14 GitOps 发布、SecretRef preflight、runner/host 边界和镜像发布:[docs/reference/g14-gitops-cicd.md](docs/reference/g14-gitops-cicd.md)
- G14 GitOps CI/CD、Tekton/Argo CD、集群内 registry 和无锁镜像化发布:[docs/reference/g14-gitops-cicd.md](docs/reference/g14-gitops-cicd.md)
- Code Agent 对话就绪与真实回复判定:[docs/reference/code-agent-chat-readiness.md](docs/reference/code-agent-chat-readiness.md)
- DEV runtime hotfix runbook 与只读审计:[docs/reference/dev-runtime-hotfix-runbook.md](docs/reference/dev-runtime-hotfix-runbook.md)
@@ -70,10 +70,10 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥
- Cloud Web 构建:`npm run web:build`
- 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`
- G14 artifact build helper`node scripts/g14-artifact-publish.mjs --publish ...`,只能由 G14 Tekton task 携带 CI artifact identity 调用;人工发布走 G14 poller/GitOps,不走 legacy `ci-publish`
- G14 artifact build helper`node scripts/g14-artifact-publish.mjs --publish ...`,只能由 G14 Tekton task 携带 CI artifact identity 调用;人工发布走 G14 poller/GitOps,不走 legacy CLI CD
- G14 GitOps 渲染:`npm run g14:gitops:render`;检查已生成 Tekton/Argo CD manifests`npm run g14:gitops:check`
- DEV 依赖 runtime base 构建:`npm run dev-runtime-base:build`
- Legacy D601 DEV CD`dev-cd-apply`/`ci-publish` 仅供迁移事故回放,不作为 G14 DEV/PROD 发布入口
- Legacy D601 DEV CD旧脚本入口已删除;事故回放只读历史 issue/commit,不恢复旧命令
- CI/CD job 查询:`node tools/hwlab-cli/bin/hwlab-cli.mjs cicd status|logs|report <jobId>`
- runner GitHub 可见性预检:`npm run runner:issue-visibility:preflight`
- D601 k3s 只读观测:legacy 回溯入口,仅在确认需要 D601 事故复盘时使用;当前 G14 运行面观察使用 UniDesk route `G14:k3s`
-264
View File
@@ -1,264 +0,0 @@
# HWLAB DEV Artifact Publish
`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 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
- Environment: `dev`.
- Namespace: `hwlab-dev`.
- Default registry prefix: `127.0.0.1:5000/hwlab`.
- Base image: resolved by `scripts/preflight-dev-base-image.mjs` from
`HWLAB_DEV_BASE_IMAGE`, a local `node:20-*` Docker image, or a local HWLAB
base tag such as
`127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim`. There is no
base-image pull or third-party fallback during publish; the build runs with
`--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, 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`
- `hwlab.pikastech.local/commit`
- `hwlab.pikastech.local/service-id`
- `hwlab.pikastech.local/environment=dev`
## Commands
Static check:
```sh
node --check scripts/preflight-dev-base-image.mjs
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:
```sh
node scripts/preflight-dev-base-image.mjs
```
Preflight and report generation:
```sh
node scripts/dev-artifact-publish.mjs --preflight
```
Build only:
```sh
node scripts/dev-artifact-publish.mjs --build
```
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
```
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:
```sh
node web/hwlab-cloud-web/scripts/check.mjs
```
For `hwlab-cloud-web`, the build path refreshes and verifies the static bundle
before image build; publish still goes through the transaction command above:
```sh
node web/hwlab-cloud-web/scripts/build.mjs
node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json
```
The runtime wrapper serves `/app/web/hwlab-cloud-web/dist` before the source
directory. If `dist` is stale, a newly tagged image can still serve old HTML,
CSS, and JavaScript. A publish for Cloud Web is not valid until the image has
fresh `dist` evidence. `build.mjs` regenerates `dist` and verifies every
runtime file and route alias matches source. `dev-artifact-publish.mjs`
re-runs that build/freshness gate and records
`artifactPublish.services[].distFreshness` for `hwlab-cloud-web` before the
Docker build. `check.mjs` remains a clean-checkout source contract and must not
fail only because ignored `dist/` is absent.
Use `--registry-prefix` only for another localhost/private/internal D601
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
`scripts/validate-dev-gate-report.mjs`, while the `artifactPublish` object is
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:
- `serviceId`
- `image`
- `imageTag`
- `digest`
- `distFreshness` for `hwlab-cloud-web` build/publish runs
- `publishEnabled`
- `artifactRequired`
- `artifactScope`
- `notPublishedReason`
- `runtimeKind`
- `implementationState`
- `sourceState`
- `entrypoint`
The report also carries `artifactPublish.serviceInventory` and
`artifactPublish.publishPlan`. `serviceInventory` is the resolved frozen service
list with enabled/disabled state. `publishPlan` is the machine-readable v2 plan:
source commit, image tag, per-service registry target, image reference, digest
placeholder or real digest, registry capability evidence, and the reason a
service was not published.
The report also includes `artifactPublish.registryCapabilities`, split into
three dimensions:
- `process-http-access`: runner-process HTTP access to the registry `/v2/`
endpoint. This is diagnostic only. A failed
`http://127.0.0.1:5000/v2/` fetch from the runner process is `degraded`, not
proof that Docker publish failed.
- `docker-daemon-push-access`: Docker daemon view of the local/internal
registry target. This is the publish-path capability and is the only registry
capability that blocks `--publish` before a push attempt.
- `k3s-pull-access`: read-only k3s view of `hwlab-dev` image pull state. This
is the deploy-path capability and does not prove or disprove Docker daemon
push access.
`digest` is only set to a registry digest after `docker push` succeeds and the
push output contains a `sha256:<64 hex>` digest. If the push succeeds but no
digest is observable, the service remains `published_unverified_digest`,
`digest` stays `not_published`, and the report carries a blocker. The script
records blockers instead of claiming a publish when build, push, digest
observation, base image, registry, contract, or safety checks fail.
After a fully successful publish, update the catalog from the report:
```sh
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report /tmp/hwlab-dev-gate/dev-artifacts.json
```
If publish remains blocked, refresh only commit/tag identity and keep digests
blocked:
```sh
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked
```
Before any later DEV apply review, run the read-only artifact/runtime guard so
artifact evidence is not mistaken for deployed runtime evidence:
```sh
node scripts/artifact-runtime-readiness-guard.mjs --target-ref origin/main --check --no-report --expect-blocked
```
The guard must remain blocked until the target commit, publish report, artifact
catalog, desired-state files, Cloud API `/health/live`, and Cloud Web
`:16666/health/live` all report the same identity. It does not publish, deploy,
restart services, mutate Kubernetes, touch PROD, or read secrets.
The report also includes `artifactPublish.baseImagePreflight` with the image
source, local tag, local image ID, `publishUsable` gate, blockers, and next
steps. It also carries `recommendation`, `provision`, and `blockedReport`
objects so the blocked report includes the exact blocker, expected image tag,
recommended `HWLAB_DEV_BASE_IMAGE` value, and preload commands. When that
preflight is blocked, artifact publish is blocked and no build or push is
attempted.
If the D601 builder has no approved Node 20 base image, or the selected DEV
registry is not reachable from the Docker daemon publish path, the report must
remain `status: "blocked"`. Do not turn a base-image or registry blocker into a
fake digest or published state. Do not treat runner-process loopback HTTP
failure as a publish failure by itself.
Committed DEV artifact reports are evidence snapshots and may lag the deploy
desired-state files. Use `scripts/deploy-desired-state-plan.mjs` to see whether
the report source matches the desired-state commit, then refresh the catalog
from a publish report only when that report proves the same target source
commit. If a later read-only runner cannot fetch catalog manifests from
`127.0.0.1:5000`, treat that as a `#66` registry reachability dimension, not as
a missing base-image or missing-publish blocker.
## Known Implementation States
- `repo-entrypoint`: the repository has `cmd/<serviceId>/main.mjs`.
- `static-web-wrapper`: the cloud web static files are packaged behind a small
health/static-file wrapper.
- `repo-bundle`: the image packages repository-owned skill artifacts.
- `library-only`: the repository has library code but no executable package bin.
- `missing-runtime-entrypoint`: the image is only a health placeholder and is
not a real service implementation.
## Known Source States
- `source-present`: the service remains in the MVP artifact catalog and has a
repository-owned source path or entrypoint conclusion.
- `intentionally-disabled`: the service is explicitly out of the MVP artifact
publish set until it is given source. It must not remain a vague blocker.
Runtime implementation blockers do not fake a service implementation. They are
recorded as the next minimum work needed after artifact publication.
-347
View File
@@ -1,347 +0,0 @@
# HWLAB DEV Deploy Apply
This document records the DEV-only apply/preflight backend for
`pikasTech/HWLAB#33` and the apply-plan/report path for
`pikasTech/HWLAB#50`. Real apply 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 apply backend
targets only the D601 `hwlab-dev` namespace and must not be used for PROD.
## Scope
- Work from latest `origin/main`.
- Read only HWLAB repository deploy inputs: `deploy/deploy.json`,
`deploy/artifact-catalog.dev.json`, and `deploy/k8s/dev`.
- Use `scripts/deploy-desired-state-plan.mjs` as source/dry-run support to
review commit, image, tag, workload image, and env mirror convergence before
any apply review.
- Validate artifact identity, image commit tags, Kubernetes workload/service
shape, DEV health, and cloud-api DB connectivity.
- Validate that `hwlab-cloud-api` desired state declares the Code Agent
provider env contract: `OPENAI_API_KEY` from
`hwlab-code-agent-provider/openai-api-key` and
`HWLAB_CODE_AGENT_OPENAI_BASE_URL` through the DEV egress/proxy path.
- Emit an operator-readable plan in `devDeployApply`: plan/apply boundary,
expected artifact commit, target namespace, workloads, services, remaining
blockers, minimal manual commands, and rollback hints.
- Plan and, in apply mode, execute a scoped replacement for DEV suspended
template Jobs whose image changed but whose Kubernetes `spec.template` is
immutable.
- Plan and, in apply mode, execute a scoped cleanup for stale DEV simulator
Deployments `hwlab-gateway-simu` and `hwlab-box-simu` only when the source
manifest contains their indexed StatefulSet replacements. This keeps the M3
DEV runtime at exactly two gateway-simu sessions and two box-simu resources.
- Stop before mutation when artifact publish evidence, registry digests,
`kubectl`, DEV health, cloud-api DB connectivity, or Code Agent provider
env preservation are missing.
## Commands
Run a preflight that is expected to report blockers on the current skeleton:
```sh
node --check scripts/dev-deploy-apply.mjs
node --check scripts/src/dev-deploy-apply.mjs
node --check scripts/dev-runtime-migration.mjs
node --check scripts/src/dev-runtime-migration.mjs
node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --check
node scripts/dev-runtime-migration.mjs --check
node scripts/artifact-runtime-readiness-guard.mjs --target-ref origin/main --check --no-report --expect-blocked
node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --report /tmp/hwlab-dev-gate/report.json
node scripts/validate-dev-gate-report.mjs
```
Run the existing CLI dry-run surface:
```sh
node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run
```
Only after CI publish evidence and executor prerequisites exist, a real DEV
CD transaction is:
```sh
node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json
```
The underlying apply backend still requires explicit non-production
confirmation and a transaction environment:
```sh
node scripts/dev-deploy-apply.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json
```
Direct `--apply` without `HWLAB_CD_TRANSACTION_ID` is rejected with
`cd-transaction-required`; this is a bypass guard, not a second lock. The
script refuses PROD flags, secret-read flags, heavyweight e2e flags, and
force-push flags. It also refuses to apply if the DEV artifact catalog still
contains skeleton-only unpublished images.
`deploy-desired-state-plan.mjs` is not an apply preflight substitute. Pair it
with `artifact-runtime-readiness-guard.mjs` before apply review so stale
artifact catalogs, stale desired-state, stale API runtime identity, or a stale
Cloud Web `:16666` build stay explicit blockers.
Kubeconfig selection is explicit and safe. The script uses this precedence:
1. `--kubeconfig PATH`
2. `HWLAB_DEV_KUBECONFIG`
3. `KUBECONFIG`
4. `/etc/rancher/k3s/k3s.yaml`
The selected path is used for read-only preflight, dry-run planning, and the
DEV-only apply path. The report records the kubeconfig source and path, but it
does not print kubeconfig contents or Secret values.
Emergency D601 guard: for normal D601 execution, prefer
`/etc/rancher/k3s/k3s.yaml`. Docker Desktop Kubernetes has been disabled and
cleaned, but a residual default kubeconfig may still point at
`docker-desktop`/`127.0.0.1:11700`; do not pass that path through `KUBECONFIG`
or `--kubeconfig`. Any selected kubeconfig whose context/node resolves to
`docker-desktop` or `desktop-control-plane` is a blocker, not DEV-LIVE
evidence. See [pikasTech/unidesk#138](https://github.com/pikasTech/unidesk/issues/138).
Runtime DB migration is a separate DEV-only apply boundary. The source-only
check is safe for normal PR validation:
```sh
node scripts/dev-runtime-migration.mjs --check
node scripts/dev-runtime-migration.mjs --dry-run --report /tmp/hwlab-dev-gate/report.json
```
Read-only live DB verification requires explicit DEV confirmation and an
already injected `HWLAB_CLOUD_DB_URL`; the report redacts the value and keeps
auth, schema, migration ledger, and readiness blockers separate:
```sh
node scripts/dev-runtime-migration.mjs --dry-run --allow-live-db-read --confirm-dev --report /tmp/hwlab-dev-gate/report.json
```
A real migration write is not part of default deploy validation. It requires a
separately authorized DEV operation:
```sh
node scripts/dev-runtime-migration.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json
```
That command may only apply
`internal/db/migrations/0001_cloud_core_skeleton.sql` to the DEV Postgres target
provided by `HWLAB_CLOUD_DB_URL`. It must not read Kubernetes Secret resources,
print the DB URL/password/token, mutate PROD, restart services, or claim
M3/M4/M5 acceptance.
`deploy-desired-state-plan.mjs` is not an apply preflight substitute. It is a
read-only source planner and does not prove registry image existence, DEV
cluster pullability, DEV apply, service restart, or M3 DEV-LIVE evidence.
## Report Contract
The machine-readable result is written to
`/tmp/hwlab-dev-gate/dev-deploy-report.json` when `--report /tmp/hwlab-dev-gate/report.json` is used. The
report remains compatible with the #33 DEV gate validator and adds these
operator fields:
- `devDeployApply.conclusion`: `blocked` while blockers are open, otherwise
`ready` for human approval.
- `devDeployApply.applyBoundary`: default no-write behavior, required apply
flags, DEV-only write scope, no-write scope, and forbidden actions.
- `devDeployApply.artifactPlan`: expected artifact commit, source commit,
deploy/catalog commit IDs, publish/registry status, and unpublished services.
- `devDeployApply.target`: DEV endpoint and `hwlab-dev` namespace.
- `devDeployApply.workloadPlan` and `servicePlan`: planned Kubernetes
workloads, containers, images, service IDs, ports, and replica intent.
- `devDeployApply.templateJobReplacementPolicy`: the DEV-only allowlist for
suspended template Job replacement. The only allowlisted Jobs are
`hwlab-agent-worker-template` and `hwlab-cli-template` in `hwlab-dev`.
- `devDeployApply.templateJobReplacements`: per-Job replacement decisions with
namespace, Job name, old image, new image, and result (`planned`, `replaced`,
`not_needed`, `not_found`, or blocked/failure states).
- `devDeployApply.legacySimulatorDeploymentCleanupPolicy`: the DEV-only
allowlist for stale simulator Deployment cleanup after the M3 simulator
runtime moved to indexed StatefulSets.
- `devDeployApply.legacySimulatorDeploymentCleanups`: per-Deployment cleanup
decisions for `hwlab-gateway-simu` and `hwlab-box-simu`, including old/new
images and result (`planned`, `deleted`, `not_found`, or blocked/failure
states).
- `devDeployApply.cloudWebRollout`: Cloud Web source commit, image tag, image
reference, digest, live image, and Kubernetes rollout revision when read-only
Deployment access is available. If access is missing, this field records the
read/rollout commands and the blocker instead of claiming a live rollout.
- `devDeployApply.codeAgentProvider`: no-secret provider validation gate. It
records desired-state env/ref readiness plus live `hwlab-cloud-api`
Deployment env preservation before apply and, in apply mode, after apply.
This gate reads Deployment env metadata only; it must not read Kubernetes
Secret data or attempt a provider call.
- `devDeployApply.manualCommands`: dry-run/report commands before human
approval; the apply command is present only when the plan is ready.
- `devDeployApply.rollbackHint`: pre-apply capture commands, deployment
rollbacks, suspended job cleanup, and post-rollback health checks.
- `devDeployApply.remainingBlockers`: open blockers with concrete unblock
hints. These do not claim live apply happened.
When `conclusion.status` is `blocked`, operators should clear the listed
`remainingBlockers` and rerun:
```sh
node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --report /tmp/hwlab-dev-gate/report.json
node scripts/validate-dev-gate-report.mjs
```
When `conclusion.status` is `ready`, run the dry-run plan and validator
immediately before human approval:
```sh
node scripts/dev-deploy-apply.mjs --dry-run --report /tmp/hwlab-dev-gate/report.json
node scripts/validate-dev-gate-report.mjs
```
Only after that approval may the DEV CD transaction command be used.
## Cloud Web Manual DEV Rollout
This is the currently verified manual CD path for `hwlab-cloud-web` on D601.
It is the operational baseline that future CLI automation should reproduce from
`deploy/deploy.json`; do not rediscover ports or kube contexts during an
incident.
Start from the D601 deployment copy, not a runner worktree:
```sh
cd /home/ubuntu/workspace/hwlab
git pull --ff-only origin main
```
Validate source in a clean checkout, then refresh the static bundle before
artifact publish. Cloud Web images serve `web/hwlab-cloud-web/dist` first, so
stale ignored build output can keep an old English or old layout bundle alive
even after the source commit is correct. `check.mjs` does not require ignored
`dist/`; `build.mjs` and `dev-artifact-publish.mjs` are the build-first
freshness gates.
```sh
node web/hwlab-cloud-web/scripts/check.mjs
node web/hwlab-cloud-web/scripts/build.mjs
node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json
```
Probe the image locally before touching k3s:
```sh
docker run --rm -d --name hwlab-cloud-web-probe -p 127.0.0.1:18088:8080 127.0.0.1:5000/hwlab/hwlab-cloud-web:<tag>
sleep 1
curl -fsS http://127.0.0.1:18088/health/live
curl -fsS http://127.0.0.1:18088/ | sed -n '1,40p'
docker rm -f hwlab-cloud-web-probe
```
Use the D601 native k3s kubeconfig explicitly:
```sh
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev set image deployment/hwlab-cloud-web hwlab-cloud-web=127.0.0.1:5000/hwlab/hwlab-cloud-web:<tag>
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev rollout status deployment/hwlab-cloud-web --timeout=180s
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev get deploy hwlab-cloud-web -o wide
```
Do not use the default kube context as the source of truth. On D601 it may be
`docker-desktop`, which is not the public DEV runtime behind `16666/16667`.
Public acceptance must verify all of these:
```sh
curl -fsS http://74.48.78.17:16666/health/live
curl -fsS http://74.48.78.17:16666/ | sed -n '1,80p'
curl -fsS http://74.48.78.17:16666/styles.css | rg 'overflow: hidden|100dvh'
curl -fsS http://74.48.78.17:16666/help.md | sed -n '1,40p'
```
For the Cloud Workbench Chinese/no-outer-scroll rollout, the accepted target
state was:
- `GET /health/live` reports revision
`1e8805664970839b72be40c34636b08f6d18b131`;
- `GET /` returns `html lang="zh-CN"` and title `HWLAB 云工作台`;
- CSS locks the outer shell with `html, body { overflow: hidden; }` and
`.workbench-shell { height: 100dvh; overflow: hidden; }`;
- `GET /help.md` returns the Chinese Cloud Workbench help document.
The public path is:
1. master `hwlab-frps-dev` listens on `16666/16667`;
2. D601 `hwlab-frpc` runs in `hwlab-dev` from the `hwlab-frpc-config`
ConfigMap;
3. `hwlab-dev-cloud-web` maps to
`hwlab-cloud-web.hwlab-dev.svc.cluster.local:8080`;
4. `hwlab-dev-edge-proxy` maps to
`hwlab-edge-proxy.hwlab-dev.svc.cluster.local:6667`.
Master `hwlab-browser-proxy-dev` is only an auxiliary local proxy on `18087`;
it is not the public browser endpoint.
## Suspended Template Job Replacement
Kubernetes Jobs cannot update `spec.template` in place. For DEV template Jobs
that are intentionally suspended and used as templates, an image tag change
would otherwise make a normal `kubectl apply -k deploy/k8s/dev` non-idempotent.
The apply script handles only the explicit DEV allowlist:
- `hwlab-agent-worker-template`
- `hwlab-cli-template`
For each allowlisted Job, the script reads the live Job in `hwlab-dev` and
compares its container image with the desired manifest image. If the live Job
exists, is suspended, and the image differs, apply mode deletes that Job before
running the normal kustomize apply so Kubernetes recreates it from the desired
manifest. Dry-run mode records the same decision as `planned` and runs
server-side kustomize dry-run without mutating the cluster. If that dry-run
reports only the expected immutable `spec.template` errors for the planned
allowlisted Jobs, the report records those errors as expected replacement
evidence instead of treating them as blockers.
The policy refuses any other Job name, any non-`hwlab-dev` namespace, and any
live Job that is not suspended. It does not change Deployment, Service,
ConfigMap, Secret, or PROD behavior.
## Current Result
The current DEV apply contract is published for artifact commit `cb35ada`.
`deploy/artifact-catalog.dev.json`, `deploy/deploy.json`, and the DEV
workloads point at `127.0.0.1:5000/hwlab/*:cb35ada` images with sha256 digest
evidence.
On Code Queue runners, the approved mounted kubeconfig path is typically
`/var/lib/unidesk/code-queue/kubeconfig`. Export `HWLAB_DEV_KUBECONFIG` or
pass `--kubeconfig` explicitly when the default D601 path is not available:
```sh
HWLAB_DEV_KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --report /tmp/hwlab-dev-gate/report.json
```
The D601 runner can read and apply the `hwlab-dev` namespace. A real DEV apply
may replace only the allowlisted suspended template Jobs above; the normal
Deployment, Service, ConfigMap, and health-contract apply path remains
unchanged.
`hwlab-cloud-api` has a DEV DB env contract placeholder:
`HWLAB_CLOUD_DB_URL` must come from Secret reference
`hwlab-cloud-api-dev-db/database-url`, and
`HWLAB_CLOUD_DB_SSL_MODE=disable`. Runtime readiness dials the redacted host
parsed from that Secret URL. `cloud-api-db` remains an optional desired alias,
not a readiness gate or TCP probe target, until this repo owns its
Service/Endpoint rollout contract. The local health gate reports DB runtime
config without printing the connection string.
Durable runtime migration now has a repo-owned path:
`scripts/dev-runtime-migration.mjs --check` proves the source migration and
ledger contract without DB access; `--dry-run --allow-live-db-read
--confirm-dev` verifies live auth/schema/migration/readiness without writes;
`--apply --confirm-dev --confirmed-non-production` is the only DEV DB write
mode and remains outside default PR validation.
No PROD resource, secret value, UniDesk runtime substitute, browser e2e,
heavyweight e2e, or force push is part of this flow.
Source plans, dry-run reports, and desired-state convergence checks are M3
support only. They must not be labelled DEV-LIVE without a real DEV observation
of the `DO1 -> patch-panel -> DI1` loop plus operation, trace, audit, and
evidence records.
-90
View File
@@ -1,90 +0,0 @@
# M2 DEV Deployment Smoke
`m2-dev-deploy-smoke` is a local dry-run smoke harness for the HWLAB DEV
deployment shape. It validates the frozen route phases, service identities,
artifact metadata, and safety gates without contacting the real DEV ingress by
default.
Run the dry-run from the repository root:
```sh
node scripts/m2-dev-deploy-smoke.mjs --dry-run
```
## What It Checks
- Parses `fixtures/dev-deploy-smoke/dev-deploy-smoke.json`.
- Verifies the route phases in order:
`master-edge-proxy`, `frp-tunnel`, `d601-router`, `cloud-surface`,
`agent-runtime`, and `sim-and-patch-panel`.
- Validates the frozen HWLAB service IDs for master edge proxy, frp tunnel,
D601 `hwlab-dev/hwlab-router`, cloud-api/web, agent, sim, and patch-panel.
- Checks `commitId`, `image`, `digest` or `not_applicable`, `tag`,
`buildSource`, `deployEnv`, `healthTimestamp`, and failure classification
for every recorded service contract.
- Confirms the cloud surface bundles `hwlab-cloud-api` and `hwlab-cloud-web`
artifacts together under the same route phase.
- Marks the fixture as dry-run evidence only. Any DB fields in fixture health
records are contract placeholders, not live DB evidence.
- Keeps the default mode in dry-run and refuses real requests unless an
explicit live gate is supplied.
## Live Gate
The live gate is intentionally strict. It is only for a future real DEV smoke
observation, not for deployment, and this harness stays fixture-only unless the
operator supplies the live flags. The public DEV endpoint split is:
- browser/frontend: `http://74.48.78.17:16666/`
- API/edge health: `http://74.48.78.17:16667/health` and
`http://74.48.78.17:16667/health/live`
- legacy public `:6667`: deprecated and never accepted as active green evidence
Required flags:
```sh
node scripts/m2-dev-deploy-smoke.mjs --live --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json
```
Before using `--live`, confirm all of the following:
- the API/edge target remains `http://74.48.78.17:16667`;
- the frontend target remains `http://74.48.78.17:16666`;
- the workspace is not a PROD or shared deployment context;
- no prohibited action is being attempted;
- the request is observational only and does not mutate runtime state.
The script rejects live execution if any of these are missing:
- `--confirm-dev`
- `--confirmed-non-production`
- `--prod`
- `--heavyweight-e2e`
- `--read-secret`
- `--force-push`
When `--report /tmp/hwlab-dev-gate/report.json` is provided, the script writes
`/tmp/hwlab-dev-gate/dev-m2-deploy-smoke-active.json`. The report contains only
read-only GET evidence and marks legacy `:6667` observations as ineligible for
active green.
Validate the written report with:
```sh
node scripts/validate-m2-deploy-smoke-active.mjs
```
## Boundary
This harness does not:
- deploy to DEV or PROD;
- call the real DEV endpoint in dry-run mode;
- run heavy e2e coverage;
- read secrets or tokens;
- replace HWLAB runtime services with UniDesk substitutes;
- touch L5 deploy manifests.
The fixture is a dry-run record only. It exists to prove that the M2 smoke
entry point understands the frozen route and artifact contract before any live
DEV observation is attempted.
+1 -1
View File
@@ -262,5 +262,5 @@ blockers/failures`viewport`、`selector`、`failureType`、summary 和 artifa
chat 同源 readiness 和 provider credential blocker 边界。
- [docs/cloud-web-workbench.md](../cloud-web-workbench.md): detailed frontend
contract.
- [docs/dev-deploy-apply.md](../dev-deploy-apply.md): Cloud Web manual rollout
- [g14-gitops-cicd.md](g14-gitops-cicd.md): Cloud Web rollout through G14 Tekton, GitOps and Argo CD
path.
-821
View File
@@ -1,821 +0,0 @@
# HWLAB Deployment And Publish Reference
This reference records the stable DEV publish and rollout rules. It does not
authorize PROD, service restarts outside the documented DEV path, secret reads,
or direct pushes to `main`.
It is also the long-term reference for
[pikasTech/HWLAB#116](https://github.com/pikasTech/HWLAB/issues/116): service
deployment regularization must progress from stable docs, to controlled
CLI/script entrypoints, to UniDesk CI/CD plus imageized delivery.
## D601 Native k3s Emergency Guard
HWLAB DEV deploy and publish paths must target D601 native k3s only. Docker
Desktop Kubernetes on D601 has been disabled and cleaned; it must not be
re-enabled or used as a second orchestrator. The cross-repo incident, evidence,
and governance plan are tracked in
[pikasTech/unidesk#138](https://github.com/pikasTech/unidesk/issues/138), and
the 2026-05-23 commander recovery context is in
[pikasTech/unidesk#118](https://github.com/pikasTech/unidesk/issues/118).
Every DEV CD status, apply, rollback, smoke, and manual Kubernetes command must
use `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` and verify node `d601` before any
mutation or acceptance. Bare `kubectl`, `docker-desktop` context,
`desktop-control-plane`, or `127.0.0.1:11700` are wrong-control-plane signals,
not HWLAB DEV-LIVE evidence. A second `hwlab-dev` control plane, including a
Docker Desktop cluster that happens to contain similarly named resources, is a
blocker and cannot be used as deploy, rollout, smoke, or acceptance proof.
## Workspaces
| Purpose | Path |
| --- | --- |
| Runner and commander worktree | `/workspace/hwlab` |
| D601 shared source cache | `/home/ubuntu/workspace/hwlab` |
| D601 clean CI/CD worktree | `/tmp/hwlab-cicd-<target-short-sha>` |
| D601 runtime experiment worktree | `/tmp/hwlab-<purpose>-<target-short-sha>` |
Git remote is the source of truth. D601 may contain several HWLAB clones or
worktrees from earlier reviews, Code Queue runs, runtime experiments, or CI/CD
jobs; none of those local directories is authoritative by itself. Before using
any D601 repo path for checks, publish, or CD, verify its role, `origin`, HEAD,
and cleanliness.
`/home/ubuntu/workspace/hwlab` is the shared source cache and convenient seed
clone. It is allowed to be stale, behind `origin/main`, or dirty with generated
`reports/dev-gate/**` files from previous runs. Do not `reset --hard`, delete
reports, or overwrite that directory just to make a release command work. If it
is not clean and already at the target commit, use it only to `git fetch` and
create a detached clean worktree.
Formal CI publish and DEV CD apply must run from a clean, target-pinned
worktree such as `/tmp/hwlab-cicd-<target-short-sha>`. The worktree may be
created from the shared cache, but it must prove all of the following before
side effects:
- `git remote get-url origin` points to `git@github.com:pikasTech/HWLAB.git`
or the approved GitHub source of truth.
- `git fetch origin main` succeeds, and `git rev-parse HEAD` equals the target
commit being published or deployed.
- `git status --short` is empty.
- The `hwlab-cli cicd submit ...` job `cwd` in `state.json` points to the clean
worktree, not to a stale runner directory or a dirty shared cache.
Runtime hotfix and gateway probing may use purpose-specific D601 worktrees under
`/tmp/hwlab-<purpose>-<sha>`. Those worktrees are for copying scripts into pods,
running probes, and staging temporary patches; they are not release truth and
must not be used to infer desired state unless they are also clean and pinned to
the target commit.
Do not treat `/home/ubuntu/hwlab`, historical runner directories, previous PR
review worktrees, or pod-local copied trees as publish/build truth. If a path's
role is unclear, stop and create a new detached worktree from `origin/main`
instead of repairing the unknown directory in place.
## Master Server Check Boundary
Master server is only a control, coordination, Git, issue/PR, and short polling
entrypoint for HWLAB. It must not run HWLAB validation workloads locally:
`npm run check`, `npm run web:check`, Playwright/browser smokes, full test
suites, build/preflight jobs, image publishing, or DEV CD checks belong on
D601, Code Queue runners, or the formal CI/CD job plane.
The master-side HWLAB clone may be used for lightweight source reading,
`git status`, `git diff`, patch preparation, commits, PR creation, and polling
D601/CI/CD status. If a validation command would consume significant CPU,
memory, browser resources, Docker, k3s, registry, or long-lived process time,
skip it on master and run the equivalent command from a clean D601 worktree or
submit it through `hwlab-cli cicd submit`.
Master wrappers may start short-lived D601 commands and poll their status, but
the validation work itself must execute on D601 or CI/CD. A master-local
timeout, browser hang, or resource shortage is not DEV-LIVE evidence and must
not be turned into a product blocker; stop the local check and move the
verification to D601/CI/CD.
## Regularization Stages
| 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 | `hwlab-cli cicd submit/status/logs/report` 是正式 CI/CD 外层控制入口;`scripts/dev-artifact-publish.mjs``scripts/dev-cd-apply.mjs` 只作为后台 worker 或只读状态脚本使用。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. | Continue converging 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. |
## CI/CD CLI 非阻塞合同
HWLAB CI/CD 正式 CLI 必须遵循 `Skill(cli-spec)`:可能超过短时间的 build、
push、apply、rollout、runtime Job、smoke 和 postflight 都不得以前台阻塞命令
暴露给指挥官、runner 或 master-side wrapper。正式入口必须使用
Fire-and-Forget 模式:
```sh
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind ci-publish --concurrency 4
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind dev-cd-apply --confirm-dev --confirmed-non-production --concurrency 4
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd status <jobId>
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd logs <jobId> --tail-bytes 8000
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd report <jobId>
```
`submit` 只创建 `.state/hwlab-cicd/jobs/<jobId>/`,写入 `spec.json`
`state.json``stdout.log``stderr.log``report.json` 路径,然后启动后台
worker 并立即返回有界 JSON。`status` 仅读 `state.json``logs` 默认只返回
尾部摘要,`report` 默认只返回报告摘要;完整日志和完整报告通过返回的文件路径
渐进查看。任何正式 CLI 调用都不得因为 Docker build、registry push、
`kubectl wait --timeout` 或 rollout status 在前台长时间占用上下文。
runner pod 内执行 CD 时,`127.0.0.1:5000` 指向 pod 自身,不等于 D601 host 的
loopback registry;但 image reference 必须继续保持 `127.0.0.1:5000/...`,因为
k3s/containerd 拉镜像和 CI push 使用的是 host 视角。CD registry manifest 校验
默认先走直接 HTTP;如果 pod loopback 被拒绝,则用 Docker host-network helper
`docker run --rm --pull=never --network host`)读取同一个 registry manifest。
正式命令仍必须通过非阻塞入口提交;如需显式 registry HTTP 入口,可在提交时加
`--registry-manifest-base-url <url>`,该参数只改变 manifest 校验 URL,不改变
`deploy.json` 或 workload 里的镜像引用。
`scripts/dev-artifact-publish.mjs``scripts/dev-cd-apply.mjs` 和其他内部脚本仍
保留为 worker/backend:它们可以在后台 job 内执行长流程,但不能作为指挥面常规
入口直接运行。临时手动排障可以直接调用底层脚本,但排障结论必须回填到正式
CLI、长期参考文档或 issue,避免把一次性命令固化为新流程。
## D601 Code Queue runner 实测调试法
HWLAB CI/CD 的关键验收必须从真正使用者 runner 的容器或 pod 内复测。master
server、指挥官本地 clone、D601 host 普通目录和历史 runner 目录只能作为控制面
或辅助观察面;它们不能替代当前 Code Queue 执行面看到的 worktree、网络、
kubeconfig、registry、Docker socket、Secret/ConfigMap mount 和 CLI 行为。
实地调试顺序固定为:
1. 先从调度器、任务、pod 和进程信息确认当前承担生产调度的 Code Queue runner
或 scheduler 容器,避免误入已经废弃、空闲或历史测试容器。
2. 通过受控维护入口进入该容器,在容器内建立干净 HWLAB worktree,并快进到
`origin/main`;不得直接使用 `/home/ubuntu/hwlab` 等历史任务集合目录作为
发布真相。
如果 worktree 是从另一个本地目录 clone 出来的,必须先确认 `origin` 指向
`git@github.com:pikasTech/HWLAB.git` 或等价 GitHub source of truth,再
`git fetch origin main`。DEV CD 的 target-ref guard 会比较 HEAD 与
`origin/main`;本地 clone 的 stale `origin/main` 会触发
`target-ref-head` blocker,不能通过跳过 guard 处理。
3. 在同一个容器内执行 `node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit ...`
`status``logs``report` 查询后台 job,而不是前台直接跑长脚本。
4. 至少完成一次 CI artifact publish 和一次 DEV CD apply,再用公开
`16666/16667` health 核对 live commit、service readiness 和 blocker 字段。
5. 调试结论必须写回 issue、正式 CLI、preflight 或长期参考文档;不能把一次性
容器命令当作新的常规发布流程。
runner 内最小复测命令形态如下:
```sh
cd <clean-hwlab-worktree>
git fetch origin main
git checkout main
git pull --ff-only origin main
git status --short
KUBECONFIG=/etc/rancher/k3s/k3s.yaml \
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind ci-publish --concurrency 4
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd status <ci-job-id>
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd report <ci-job-id>
KUBECONFIG=/etc/rancher/k3s/k3s.yaml \
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind dev-cd-apply --confirm-dev --confirmed-non-production --concurrency 4
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd status <cd-job-id>
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd report <cd-job-id>
```
一次 runner 实测只有同时满足以下条件,才可判断 CI/CD 链路通畅:
- `ci-publish``dev-cd-apply` job 都进入终态 `succeeded`
- job report 中 `blockers` 为空,耗时、服务数、镜像 tag/digest 和 target commit
有界展示;
- `Lease/hwlab-dev/hwlab-dev-cd-lock` 在事务结束后释放或显示为无活动 holder;
- 公开 `16666``16667` health 的 commit 与 `deploy/deploy.json` promotion
commit 一致;
- runner 容器内没有残留 `hwlab-cli``dev-cd-apply` 或旧发布 worker 进程继续
持有写路径。
本次 CI/CD 疏通暴露出的稳定故障类别也必须长期前置:
- `127.0.0.1:5000` 在 runner pod 内、Docker host-network helper 内和 k3s
containerd 内含义不同。镜像引用仍使用 `127.0.0.1:5000/...` 作为 D601 host
registry identityregistry manifest 校验按运行视角选择直接 HTTP 或 Docker
host-network helper,不得为了 pod 视角改写 release identity。
- Codex stdio 所需 `auth.json` Secret 和 `config.toml` ConfigMap 是发布前置
条件。preflight 只检查对象和 key 名,不读取 secret valueConfigMap 属于
repo desired-stateSecret 属于外部环境。
- 已经恢复的 live 服务不能替代正式 CD 报告。若第一次 CD 因缺少 mount、
registry 视角或权限失败,即使手动恢复了 live,也必须再次从 runner 内走
非阻塞 DEV CD job,产出通过报告。
- CI 产物事实以 registry manifest 和 artifact catalog 为准;临时
`/tmp/hwlab-dev-gate/*.json` 只用于审计和耗时排障,不得成为 CD 放行硬依赖。
- `kubectl apply -k` 不会自动移除由运行面 `kubectl patch`、临时 ConfigMap
热修或其他 field manager 写入、且不属于 desired manifest / last-applied 的
Deployment `volumes``volumeMounts` 或 template annotations。正式 CD 不能把
“镜像 tag 已更新”误判为“热修覆盖已撤销”。DEV CD apply 必须在 apply 前比对
desired Deployment 与 live Deployment:若 live 存在 desired 不包含、且明确带
`hotfix`/`override` 语义的额外覆盖字段,应在 `hwlab-dev` 内删除该 desired
Deployment,再由同一次 `kubectl apply -k deploy/k8s/dev` 从源码 desired-state
重新创建;报告中记录 cleanup 计划和实际动作。不要把这种 cleanup 做成某个服务
或某个 skill 的特例。
- Kubernetes service links 会为同 namespace Service 自动注入形如
`HWLAB_CLOUD_API_PORT=tcp://...` 的环境变量。应用入口不得直接把这类 env 当作
数字端口解析;desired-state 应显式声明服务自身监听端口
`HWLAB_CLOUD_API_PORT=6667`,入口代码也必须只接受合法数值端口,否则回落到
内置默认值。
## DEV CD Transaction
正式 DEV CD 写路径只有一个外层入口:
```sh
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind dev-cd-apply --confirm-dev --confirmed-non-production --concurrency 4
```
该命令后台调用 `scripts/dev-cd-apply.mjs`,是唯一允许按 `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:强制
`KUBECONFIG=/etc/rancher/k3s/k3s.yaml`,验证节点包含 `d601`,拒绝
`docker-desktop``desktop-control-plane``127.0.0.1:11700`、裸
`kubectl` 指向第二套 `hwlab-dev` 控制面或其他不可信控制面信号,并且只验证
必要 SecretRef 的存在性和 key 名。通过后才获取 Lease、串行执行内部步骤、
按需写入 `/tmp/hwlab-dev-gate/dev-cd-apply.json`,最后释放 Lease。
apply preflight 检查的 SecretRef 固定为:
- `hwlab-cloud-api-dev-db/database-url`
- `hwlab-cloud-api-dev-db-admin/admin-url`
- `hwlab-code-agent-provider/openai-api-key`
- `hwlab-code-agent-codex-auth/auth.json`
Code Agent Codex stdio 还必须在 preflight 阶段验证非 secret ConfigMap
`hwlab-code-agent-codex-config/config.toml` 的存在性和 key 名;该 ConfigMap 由
`deploy/k8s/base/code-agent-codex-config.yaml` 纳入 DEV desired-state。Codex
auth 仍然只能作为外部 Secret 管理,禁止把 `auth.json` 内容写进仓库或报告。
preflight 和报告只能记录 Secret 名、key 名、存在性、key-name 观察结果和脱敏
命令摘要;不得读取或打印 Secret value。阻塞时输出结构化 blocker,包含
`scope``reason``impact``safeNextAction``retryable`,默认 stdout 只
给有界摘要,完整 preflight 证据写入事务报告。
`deploy/deploy.json``commitId` 是运行时 promotion commit。正常
latest-main 发布时它可以等于当前 target refartifact merge commit 或后续
控制面提交存在时,它也可以指向一个已经发布并在 catalog/workloads 中完全收敛
的早期 promotion commit。`scripts/dev-cd-apply.mjs` 必须先用
`deploy-desired-state-plan --promotion-commit <commit>` 验证该 desired-state
已经发布且内部一致;验证通过后,`--apply` 消费这个 promotion commit 的镜像
执行 apply/verify,不重新 publish 或刷新当前 HEAD 的 artifact。这样 host
commander 可以始终从最新 `origin/main` 控制入口执行正式 DEV CD,而不需要
checkout promotion parent 或手工拼装发布环境。
状态面和事务报告会显式展示这条边界:`target.controlRef` 是最新
`origin/main` 控制入口,`target.promotionCommit` 是从 `deploy/deploy.json`
解析出的真实 promotion commit`target.artifactBoundary` 记录最近一次触碰
`deploy/deploy.json``deploy/artifact-catalog.dev.json`
`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`
`transactionId``phase``startedAt``updatedAt``ttlSeconds`
`liveBefore``targetNamespace``deploy/deploy.json`
`deploy/artifact-catalog.dev.json``deploy/k8s/base/workloads.yaml` 仍是唯
一权威部署输入。
如果另一个事务持有 Lease`--apply` 必须在 publish/apply 之前停止,并
输出结构化 `deploy-lock-held`,包含 holder、promotion commit、phase、
target namespace 和 retry seconds。过期锁不能静默覆盖;stale lock 只能
在确认 holder 已失活后显式使用 `--break-stale-lock --confirm-dev`,并把
previous holder 保留在审计 annotations/report 字段中。未过期 Lease 没有
抢占入口,普通 status 输出也不得建议强制抢锁。
`scripts/dev-cd-apply.mjs` 同时提供只读状态面。无参数、`--status`、或
`--dry-run` 都返回紧凑 JSON,覆盖 target ref/commit、
`deploy/deploy.json` commit/hash、当前 Lease 状态摘要、以及公开
`16666/16667` live health identity 摘要。状态面还包含 `liveDelta`,用于对比
当前公开 live commit 与 `target.promotionCommit`;这只是 rollout 口径,不会在
status/dry-run 中执行 rollout。这些模式 host commander 和 runner 都可以执行,
但必须保持只读:不获取或替换 Lease,不 publish artifact,不执行
`kubectl apply/rollout`,不写 report,不修改 `deploy.json`/catalog,也不读取或
打印 Secret value。kubectl 或 kubeconfig
不可用时,状态面应返回 `lock.status=unavailable` 和脱敏原因,而不是把
只读观测失败升级成写路径 blocker。
## `deploy.json` DEV CD 路径
当前已经验证可用的过渡路径是:master server 只作为受控指挥入口,通过
UniDesk CLI 进入 D601D601 上使用干净 HWLAB 工作区运行本仓库
`scripts/dev-cd-apply.mjs`;发布目标由 `deploy/deploy.json` 和同 commit
的 artifact catalog / k8s workloads 决定;真实写入只落到 D601 原生 k3s
`hwlab-dev` namespace。复盘来源见
[pikasTech/HWLAB#61](https://github.com/pikasTech/HWLAB/issues/61#issuecomment-4525401375)
长期化跟踪见 [pikasTech/HWLAB#340](https://github.com/pikasTech/HWLAB/issues/340)
单事务入口和发布锁见 [pikasTech/HWLAB#274](https://github.com/pikasTech/HWLAB/issues/274)
D601 控制面归一见 [pikasTech/unidesk#138](https://github.com/pikasTech/unidesk/issues/138)。
稳定调用合同如下;外层 master CLI 或 UniDesk wrapper 只能包裹这些
repo-owned 入口,不能绕过它们直接拼接 publish、apply、rollout 或 smoke
| 目的 | 稳定入口 | 副作用边界 |
| --- | --- | --- |
| 读取 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。 |
| 提交 CI artifact publish | `node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind ci-publish --concurrency 4` | 立刻返回 jobId;后台 build/push,报告写入 `.state/hwlab-cicd/jobs/<jobId>/report.json`。 |
| 提交 DEV CD 事务 | `node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind dev-cd-apply --confirm-dev --confirmed-non-production --concurrency 4` | 立刻返回 jobId;后台在 `Lease/hwlab-dev/hwlab-dev-cd-lock` 下 apply/verifylegacy latest-main publish 仅作过渡兼容。 |
| 查询 CI/CD job | `node tools/hwlab-cli/bin/hwlab-cli.mjs cicd status|logs|report <jobId>` | 只读 `.state/hwlab-cicd/jobs/<jobId>/`;日志和报告默认有界摘要。 |
| 读取 D601 k3s 证据 | `node scripts/d601-k3s-readonly-observability.mjs` | 只读可见性报告;不 rollout、不输出 Secret value。 |
使用 master-side wrapper 时,稳定形态是:
```sh
cd /root/unidesk
bun scripts/cli.ts ssh D601 argv bash -lc 'cd <clean-hwlab-cd-worktree> && KUBECONFIG=/etc/rancher/k3s/k3s.yaml node scripts/dev-cd-apply.mjs --status'
bun scripts/cli.ts ssh D601 argv bash -lc 'cd <clean-hwlab-cd-worktree> && KUBECONFIG=/etc/rancher/k3s/k3s.yaml node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind dev-cd-apply --confirm-dev --confirmed-non-production --concurrency 4'
```
`<clean-hwlab-cd-worktree>` 必须指向干净工作区。该工作区可以是固定 clean
mirror,也可以是一次性 `git clone --shared --no-checkout` 生成的 ephemeral
worktree;它不能是 `/home/ubuntu/hwlab` 这类 runner/历史任务集合目录。进入
apply 前必须证明:
- `git status --short` 为空;
- `.git/FETCH_HEAD``.git/worktrees` 没有阻止普通部署用户读取或创建
干净工作区的权限问题;
- `node scripts/dev-cd-apply.mjs --status` 显示 `promotionSource=deploy-json`
- `deploy/deploy.json` 的 commit、`deploy/artifact-catalog.dev.json`
`deploy/k8s/base/workloads.yaml` 收敛到同一个 promotion commit / image tag
- `KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get nodes` 能看到节点 `d601`
- `kubectl config current-context`、cluster server 或节点列表没有
`docker-desktop``desktop-control-plane``127.0.0.1:11700` 或第二套
`hwlab-dev` 控制面证据;
- `Lease/hwlab-dev-cd-lock` 未被未过期事务持有,或者 stale lock 已由受控
`--break-stale-lock --confirm-dev` 审计处理。
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 期望;CD 必须直接向 registry 校验 tag/digest 是否真实存在。 |
| `deploy/k8s/base/workloads.yaml` | Kubernetes workload desired state,必须镜像 `deploy.json` 和 artifact catalog 后才能 apply。 |
| 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`
`deploy/artifact-catalog.dev.json``deploy/k8s/base/workloads.yaml`、service
source 或 runtime manifest,就不会产生新的 DEV image 或 promotion commit。
这类 commit 的 artifact build/publish 结论是 not applicable,除非另一个
artifact 任务明确更新 desired state。
## Runner 和 Host 边界
Deployment/CD 工作按以下边界分工:
| 角色 | 可以执行 | 没有明确 rollout 授权时不得执行 |
| --- | --- | --- |
| Code Queue runner | 准备分支和 PR;在任务明确授权且检查通过后更新或自合并 docs/code/artifact PR;通过 repo-owned 路径 build/publish artifact;在被分配时刷新 artifact report 或 desired-state 文件;运行只读 `status`/`dry-run`/preflight 检查。 | 未授权自合并、竞争 DEV CD Lease、执行 DEV apply、rollout、声称 `16666/16667` live verification、收口 `#7``#242`、修改 PROD、打印 Secret value、手工修 live resource。 |
| Host commander | 在需要指挥官 review 时审阅并合并最终 PR;执行单一 DEV deploy apply 事务;执行 rollout 和 `16666/16667` live verification;收口 `#7`/`#242` 状态。 | 绕过 repo-owned CD 入口、使用第二控制面、用 UniDesk runtime 替代 HWLAB runtime、只凭报告而不做一手复验就当作 live evidence。 |
Runner 自合并权限只在单个任务和单个 PR 的显式授权边界内生效。它不授予
DEV apply、rollout、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 条件处理,而不是等待
runtime Job 进入 `CreateContainerConfigError`
- `hwlab-cloud-api-dev-db/database-url`
- `hwlab-cloud-api-dev-db-admin/admin-url`
- `hwlab-code-agent-provider/openai-api-key`
这些检查只能验证 Secret 名称、key 和 env 注入边界,不得打印 Secret value。
如果 `hwlab-cloud-api-dev-db-admin/admin-url` 缺失,短期 runbook 是由受控
DEV-only secret reconcile 从 `unidesk-dev/postgres-dev` runtime source 生成
并写入 `hwlab-dev`;长期应迁移到 declarative secret、sealed/external secret
或统一 secret controller。
SecretRef preflight report 必须使用布尔值和脱敏 metadata:
| 检查项 | 必要证据 | 缺失时 blocker |
| --- | --- | --- |
| Source desired-state reference | `deploy/deploy.json``deploy/k8s/base/workloads.yaml` 声明预期 `secretRef` / `secretKeyRef` name 和 key。 | `contract_blocker`scope 指向所属 manifest 或 service。 |
| Live D601 Secret presence | 在 `hwlab-dev` namespace 中,repo-owned preflight 或 controller 证明必要 Secret resource 和 key 存在。 | `runtime_blocker`scope 指向缺失的 SecretRef。 |
| k3s access for the check | 检查使用 `/etc/rancher/k3s/k3s.yaml`,解析到节点 `d601`,没有命中 Docker Desktop 或第二控制面。 | `environment_blocker``observability_blocker`scope 指向 D601 k3s visibility。 |
| Redaction | Report 字段声明 `secretValuesPrinted=false`;日志不包含 decoded data、DSN、token、password、kubeconfig material 或 base64 Secret payload。 | `safety_blocker`;在 publish/apply 前停止。 |
preflight 可以报告 Secret name、key name、namespace、presence boolean、
owning workload、missing scope 和 repair runbook。它不得粘贴
`HWLAB_CLOUD_DB_URL`, `HWLAB_CLOUD_DB_ADMIN_URL`, `OPENAI_API_KEY`, bearer
tokens, passwords, kubeconfig contents, or Secret `.data` values into reports,
issues, PRs, logs, screenshots, or chat.
## Blocker 分类
DEV CD report 必须保持 blocker 分类稳定,使 runner 和 host 不读完整事务日志
也能路由工作:
| Type | 含义 |
| --- | --- |
| `safety_blocker` | 请求动作会违反 DEV-only、non-secret、non-PROD、lock 或控制面安全规则。 |
| `environment_blocker` | 必要 local/D601 tool、kubeconfig、k3s node、registry、workspace 或 permission 不可用。 |
| `contract_blocker` | Source manifest、desired-state 文件、artifact catalog、report schema 或 promotion provenance 不一致。 |
| `runtime_blocker` | DEV runtime 前置条件或后置条件缺失,包括 SecretRef presence、DB readiness、durable runtime、rollout 或 workload image convergence。 |
| `agent_blocker` | Code Agent provider/model/egress/SecretRef readiness 不完整。 |
| `network_blocker` | public 或 internal endpoint 不可达,或返回无效 health response。 |
| `observability_blocker` | runner 无法观测足够只读证据来证明状态,但底层 runtime 可能仍然健康。 |
每个 blocker 必须包含 `type``scope``status` 和单行 `summary`。可行时还
应包含 `unblockHint``nextTask`。Secret 相关 blocker 必须声明没有打印
value。
成功收口必须同时具备以下证据:
- `scripts/dev-cd-apply.mjs --status` 返回 `status=pass`
- `Lease/hwlab-dev-cd-lock``phase=released``releaseStatus=pass`
- `16666/health/live` 的 Cloud Web revision 等于 `deploy.json` promotion commit
- `16667/health/live` 的 Cloud API commit、DB readiness、runtime durability 均 ready
- `KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev get deploy,statefulset`
显示 persistent workload image tag 收敛到同一 promotion commit
- 输出保持 bounded summary,完整 report 写入 `/tmp/hwlab-dev-gate/dev-cd-apply.json`
或 CLI dump 文件,避免把长 JSON 直接塞进指挥上下文。
正常 `--apply` stdout 默认也是紧凑摘要;完整事务记录继续通过
`--report /tmp/hwlab-dev-gate/report.json` 写入既有 report 路径,只有显式 `--full-output` 时才打印
完整 JSON。DEV apply、rollout 和 live verification 仍由 host commander
统一执行;runner 默认只使用 status/dry-run 观测面,不竞争 DEV CD lock。
旧的 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
DEV artifacts publish to the D601 local/internal registry:
```text
127.0.0.1:5000/hwlab/*
```
The single-step publish command is a CI artifact side-effect step:
```sh
HWLAB_CI_ARTIFACT_RUN_ID=<run-id> node scripts/dev-artifact-publish.mjs --publish
```
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:
```sh
node web/hwlab-cloud-web/scripts/check.mjs
node web/hwlab-cloud-web/scripts/build.mjs
node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json
```
Cloud Web must build `web/hwlab-cloud-web/dist` before publish because the
runtime wrapper serves `dist` before source files. A correct source commit with
stale `dist` can still publish an old UI. The build/check pair is part of the
publish contract, but with separate responsibilities: `check.mjs` validates
source and does not require ignored `dist/` in a clean checkout. `build.mjs`
regenerates `dist` and verifies every runtime file and route alias matches
source. `scripts/dev-artifact-publish.mjs` runs that build/freshness gate and
records the `distFreshness` result before it builds the image.
After a successful multi-service publish, refresh the artifact catalog from the
publish report:
```sh
node scripts/refresh-artifact-catalog.mjs --target-ref <published-source-commit> --publish-report /tmp/hwlab-dev-gate/dev-artifacts.json
node scripts/deploy-desired-state-plan.mjs --promotion-commit <published-source-commit> --check
```
`ci-publish` only proves that images for its `sourceCommitId` were built and
pushed; it does not change the rollout target by itself. The refresh target
must be the already published `sourceCommitId` from the CI publish report. A
later documentation, catalog, or desired-state commit may record the promotion,
but it is not automatically an image source and must not be passed as
`--target-ref` unless a matching publish report proves images for that exact
commit.
Before any formal `dev-cd-apply`, operators must refresh
`deploy/artifact-catalog.dev.json`, `deploy/k8s/base/workloads.yaml`, and
`deploy/deploy.json`, commit/push that promotion, and verify the desired-state
plan points at the intended `sourceCommitId`. If `deploy/deploy.json` still
names an older commit, CD will correctly keep deploying that older promotion
even when a newer publish report exists. Do not treat the newest CI publish
report, or a later bookkeeping commit, as implicit CD desired state.
If publish is blocked, keep the report blocked rather than inventing digests:
```sh
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked
node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --check
```
The desired-state files are a single deployment truth: `deploy/deploy.json`,
`deploy/artifact-catalog.dev.json`, and `deploy/k8s/base/workloads.yaml` must
carry the same promotion commit and immutable image tag before they are used as
apply input. A `--target-ref` desired-state plan may report a uniform older
state as `promotion_pending`; PR review and CI/CD promotion gates must use
`--target-ref origin/main --check` or `--promotion-commit <sha> --check` so
stale or partially refreshed commit, image, workload, and mirror-env fields are
blocked before any DEV apply.
Reports under `repository report directory ` are evidence snapshots only and cannot override this
desired-state contract.
## Latest-Main Artifact/Runtime Guard
Before any controlled DEV apply for latest `origin/main`, run the read-only
artifact/runtime guard:
```sh
node scripts/artifact-runtime-readiness-guard.mjs --target-ref origin/main --check --no-report
```
When current readiness is expected to be blocked, use:
```sh
node scripts/artifact-runtime-readiness-guard.mjs --target-ref origin/main --check --no-report --expect-blocked
```
The guard compares target main, `/tmp/hwlab-dev-gate/dev-artifacts.json`,
`deploy/artifact-catalog.dev.json`, the desired-state planner, Cloud API
`/health/live`, and Cloud Web `:16666/health/live`. It is report-only and must
not be used as an apply command. A local source check, static workbench smoke,
or stale report snapshot cannot claim latest-main deployment unless the guard's
runtime and artifact identity checks also pass.
## API, Edge, Health, And FRP
The public route is fixed:
| Surface | Current entry |
| --- | --- |
| Cloud Web | `http://74.48.78.17:16666/` |
| Cloud Web live health | `http://74.48.78.17:16666/health/live` |
| API/edge health | `http://74.48.78.17:16667/health` |
| API/live health | `http://74.48.78.17:16667/health/live` |
FRP maps master public `16666/16667` to D601 `hwlab-dev` services. Use
`deploy/deploy.json` as the source for endpoint and FRP drift checks:
```sh
node scripts/deploy-contract-plan.mjs --check
```
Do not reintroduce public `6666/6667` as active DEV endpoints. Internal k3s
services may still use internal `6667`.
## Cloud Web Manual DEV Rollout
This section is retained as historical operational baseline for what the
transaction reproduces. New DEV side effects should use
`scripts/dev-cd-apply.mjs`; do not bypass the Lease with ad hoc publish,
`kubectl set image`, or restart commands.
The DEV CD transaction owns the durable runtime support sequence before final
live postflight:
```sh
node cmd/hwlab-cloud-api/provision.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json
node cmd/hwlab-cloud-api/migrate.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json
node scripts/dev-runtime-postflight.mjs --live --confirm-dev --confirmed-non-production --target api --report /tmp/hwlab-dev-gate/report.json
```
These commands are called by `scripts/dev-cd-apply.mjs` under the transaction
Lease. Provisioning and migration run from the current `hwlab-cloud-api` image
as stable image-internal operator entrypoints; they receive DB input only
through SecretRef-backed env and print only redacted structured status. Runners
without explicit rollout authorization may run the source-only `--check`
variants and prepare PRs/artifacts, but must leave DEV apply, rollout, live
verification, and lock ownership to the host commander.
The previous verified manual path for `hwlab-cloud-web` was:
```sh
cd /home/ubuntu/workspace/hwlab
git pull --ff-only origin main
node web/hwlab-cloud-web/scripts/build.mjs
node scripts/dev-artifact-publish.mjs --publish --services hwlab-cloud-web
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev set image deployment/hwlab-cloud-web hwlab-cloud-web=127.0.0.1:5000/hwlab/hwlab-cloud-web:<tag>
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev rollout status deployment/hwlab-cloud-web --timeout=180s
```
That historical sequence is no longer the normal side-effect path; direct
`--publish` is rejected unless it is called by the transaction.
The rollout must use D601 native k3s:
```sh
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev get deploy hwlab-cloud-web -o wide
```
The rollout report must tie the live deployment back to artifact identity:
source commit, image tag, image reference, registry digest or `not_published`,
and Kubernetes rollout revision. `scripts/dev-deploy-apply.mjs` reports this
under `devDeployApply.cloudWebRollout` when read-only Deployment access exists;
without that access, it reports the blocker and the exact read/rollout commands
instead of claiming a live rollout.
Public verification must check the browser entry on `16666`, not legacy
ports:
```sh
curl -fsS http://74.48.78.17:16666/health/live
curl -fsS http://74.48.78.17:16666/ | sed -n '1,80p'
curl -fsS http://74.48.78.17:16666/styles.css | rg 'overflow: hidden|100dvh'
curl -fsS http://74.48.78.17:16666/help.md | sed -n '1,40p'
curl -fsS http://74.48.78.17:16667/health/live
```
Cloud Workbench acceptance also expects Chinese default UI, no outer page
scroll, and an internal Markdown help surface. The frontend contract is
[cloud-workbench.md](cloud-workbench.md).
## Apply Boundary
`scripts/dev-deploy-apply.mjs` is the DEV-only apply/report backend. A real
apply requires the transaction environment created by `scripts/dev-cd-apply.mjs`
plus explicit non-production flags:
```sh
node scripts/dev-deploy-apply.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json
```
Direct manual `--apply` calls without `HWLAB_CD_TRANSACTION_ID` return
`cd-transaction-required`. The apply path must stop before mutation when
artifact evidence, registry digests, kube access, DEV health, or required
runtime readiness are missing. It must not read secret values or target PROD.
Suspended template Jobs are immutable in `spec.template`. Only the explicit
DEV allowlist may be replaced by the apply script:
- `hwlab-agent-worker-template`
- `hwlab-cli-template`
## Rollback Boundary
Rollback remains DEV-only and must be explicit. Capture current image state
before changing a workload:
```sh
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev get deploy hwlab-cloud-web -o wide
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev rollout history deployment/hwlab-cloud-web
```
For a Deployment rolled by `set image`, use Kubernetes rollout undo only for
the same DEV Deployment:
```sh
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev rollout undo deployment/hwlab-cloud-web
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev rollout status deployment/hwlab-cloud-web --timeout=180s
```
After rollback, repeat public verification on `16666` and `16667`. Do not use
rollback to change PROD, read secrets, restart unrelated services, or conceal
an artifact/report mismatch.
## Controlled Entrypoint Design
The #116 target is a single controlled deployment surface. Until that lands,
use the repo-owned scripts below instead of ad hoc shell fragments.
| Action | Current controlled entrypoint | Gap to close |
| --- | --- | --- |
| Check deployment contract and FRP/endpoint drift | `node scripts/deploy-contract-plan.mjs --check` | Fold into `hwlab deploy check --env dev`. |
| 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 | `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`. |
## Automation Direction
The stable direction from
[pikasTech/HWLAB#61](https://github.com/pikasTech/HWLAB/issues/61) and
[pikasTech/HWLAB#116](https://github.com/pikasTech/HWLAB/issues/116) is to
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 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.
UniDesk CI/CD is infrastructure only. It may schedule build/publish/deploy
jobs and store artifacts, but it must not replace `hwlab-cloud-api`,
`hwlab-cloud-web`, `hwlab-router`, `hwlab-patch-panel`, simulators, gateways,
or agent services as the runtime being validated.
## Stable Sources
- [docs/dev-artifact-publish.md](../dev-artifact-publish.md): artifact publish
command and report contract.
- [docs/dev-deploy-apply.md](../dev-deploy-apply.md): apply/report and Cloud
Web manual rollout details.
- [web/hwlab-cloud-web/scripts/dist-contract.mjs](../../web/hwlab-cloud-web/scripts/dist-contract.mjs): Cloud Web `dist` freshness contract used by build/check/publish.
- [pikasTech/HWLAB#61](https://github.com/pikasTech/HWLAB/issues/61): manual
rollout review and automation target.
- [pikasTech/HWLAB#116](https://github.com/pikasTech/HWLAB/issues/116):
deployment regularization stages and acceptance.
- [pikasTech/HWLAB#340](https://github.com/pikasTech/HWLAB/issues/340):
`deploy.json` DEV CD path long-termization and master CLI automation.
+1 -1
View File
@@ -22,7 +22,7 @@ export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get nodes -o jsonpath='{.items[*].metadata.name}'
```
结果必须包含 `d601`。出现 `docker-desktop``desktop-control-plane``127.0.0.1:11700` 或第二套 `hwlab-dev` 控制面时,停止 hotfix、回滚和验收判断。此边界与 [dev-runtime-boundary.md](dev-runtime-boundary.md) 和 [deployment-publish.md](deployment-publish.md) 保持一致。
结果必须包含 `d601`。出现 `docker-desktop``desktop-control-plane``127.0.0.1:11700` 或第二套 `hwlab-dev` 控制面时,停止 hotfix、回滚和验收判断。此边界与 [dev-runtime-boundary.md](dev-runtime-boundary.md) 和 [g14-gitops-cicd.md](g14-gitops-cicd.md) 保持一致。
## kubectl 热修命令形状
@@ -1,307 +0,0 @@
{
"smokeId": "m2-dev-deploy-smoke",
"issue": "pikasTech/HWLAB#23",
"environment": "dev",
"endpoint": "http://74.48.78.17:16667",
"frontendEndpoint": "http://74.48.78.17:16666",
"legacyPublicEndpoint": {
"endpoint": "http://74.48.78.17:6667",
"status": "deprecated",
"activeGreenEligible": false
},
"defaultMode": "dry-run",
"safetyGates": {
"dryRunOnly": true,
"realRequestsRequire": [
"explicit --live flag",
"confirmed DEV endpoint",
"confirmed non-production workspace",
"no forbidden actions"
],
"forbiddenActions": [
"real-dev-deploy",
"prod-deploy",
"heavyweight-e2e",
"secret-read",
"force-push",
"unidesk-runtime-substitution"
]
},
"serviceContracts": [
{
"routePhase": 1,
"phase": "master-edge-proxy",
"serviceId": "hwlab-edge-proxy",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-edge-proxy",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:40b2218b7a1ede3d215eaf99c56caf85cd61318da4e4348122e4e2da4c072cd8",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "ok",
"route": "public-dev-ingress",
"forwardTarget": "frp",
"artifactIdentity": "master edge proxy"
},
"failureClassification": "network_blocker"
},
{
"routePhase": 2,
"phase": "frp-tunnel",
"serviceId": "hwlab-tunnel-client",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-tunnel-client",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:53bdcb1f16d8a761d836e09e9bb0fe37d3f726b85769be8658d1e2702ff3d448",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "connected",
"route": "master-edge-proxy",
"forwardTarget": "D601 hwlab-dev/hwlab-router",
"artifactIdentity": "frp tunnel"
},
"failureClassification": "network_blocker"
},
{
"routePhase": 3,
"phase": "d601-router",
"serviceId": "hwlab-router",
"namespace": "hwlab-dev",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-router",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:be7e87979bbe78fc040d52d64b264bb8494f873c14b455dbf14681e2b4fd3dfb",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "live",
"route": "hwlab-dev/hwlab-router",
"forwardTarget": "cloud-api/web",
"artifactIdentity": "D601 router"
},
"failureClassification": "runtime_blocker"
},
{
"routePhase": 4,
"phase": "cloud-surface",
"serviceId": "hwlab-cloud-api",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-cloud-api",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:a147c7dbb6df1d30b0ac5e747f2f1fc42d455a55410b82578573b9b3ae2d6074",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "ok",
"live": true,
"route": "cloud-api/web",
"db": {
"fixtureOnly": true,
"liveDbEvidence": false,
"requiredEnv": [
{
"name": "HWLAB_CLOUD_DB_URL",
"present": false,
"redacted": true
},
{
"name": "HWLAB_CLOUD_DB_SSL_MODE",
"present": false,
"redacted": false
}
],
"missingEnv": [
"HWLAB_CLOUD_DB_URL",
"HWLAB_CLOUD_DB_SSL_MODE"
],
"summary": "Fixture records the DB env contract shape only; it is not live DB evidence."
},
"artifacts": [
{
"serviceId": "hwlab-cloud-api",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-cloud-api",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:a147c7dbb6df1d30b0ac5e747f2f1fc42d455a55410b82578573b9b3ae2d6074"
,
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"serviceId": "hwlab-cloud-web",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-cloud-web",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:4a546582e6318b77530b9141dfd764c36b974442d6c7916a1294c54d0b02a14e",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
}
]
},
"failureClassification": "runtime_blocker"
},
{
"routePhase": 5,
"phase": "agent-runtime",
"serviceId": "hwlab-agent-mgr",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-agent-mgr",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:cf2dab7d0aa49ef6e77dd8e1bacebd588bf2dbd65e86a4b8c2dc23cd248c026b",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "live",
"trace": "session-scoped",
"cleanup": "no worker leak"
},
"failureClassification": "agent_blocker"
},
{
"routePhase": 6,
"phase": "agent-runtime",
"serviceId": "hwlab-agent-worker",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-agent-worker",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:be2941f18376e8e39e0aa27857bb2db4300e861f2c759a7bc447e96e1f0e8bf1",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "scoped",
"projectId": "proj_mvp-l6",
"trace": "session-scoped",
"cleanup": "worker session closed"
},
"failureClassification": "agent_blocker"
},
{
"routePhase": 7,
"phase": "agent-runtime",
"serviceId": "hwlab-agent-skills",
"commitId": "5583e4b",
"image": "skills/hwlab-agent-runtime",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "not_applicable",
"digestReason": "local dry-run fixture for repo-local skills bundle",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "traceable",
"bundle": "repo-local skills artifact",
"cleanup": "explicit commitId required"
},
"failureClassification": "agent_blocker"
},
{
"routePhase": 8,
"phase": "sim-and-patch-panel",
"serviceId": "hwlab-gateway",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-gateway",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:dcf57c4a0fab873ab571c1f6314d352d38853ff2cf125356a05a06ae5cfdb444",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "live",
"route": "hardware boundary",
"artifactIdentity": "gateway boundary"
},
"failureClassification": "runtime_blocker"
},
{
"routePhase": 9,
"phase": "sim-and-patch-panel",
"serviceId": "hwlab-gateway-simu",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-gateway-simu",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:0bc140f52733255c937d08cea4d51639c83a71df8ac7a78bb107f60401bc392e",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "live",
"route": "simulator",
"artifactIdentity": "gateway simulator",
"observedAt": "2026-05-21T00:00:00.000Z"
},
"failureClassification": "runtime_blocker"
},
{
"routePhase": 10,
"phase": "sim-and-patch-panel",
"serviceId": "hwlab-box-simu",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-box-simu",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:0506757987fb7955ec0089fd9c0d895c33eb714a940fc7b8d58153368f96e8c8",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "live",
"route": "patch-panel-owned",
"artifactIdentity": "box simulator"
},
"failureClassification": "runtime_blocker"
},
{
"routePhase": 11,
"phase": "sim-and-patch-panel",
"serviceId": "hwlab-patch-panel",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-patch-panel",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:bd3045c3235353e2437fbb11308ffe6f1ea3e52ca1e481dc80d4317a4b1600d1",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "active",
"route": "patch-panel-owned",
"artifactIdentity": "patch panel",
"observedAt": "2026-05-21T00:00:00.000Z"
},
"failureClassification": "runtime_blocker"
}
],
"observations": {
"serviceIds": [
"hwlab-edge-proxy",
"hwlab-tunnel-client",
"hwlab-router",
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-agent-skills",
"hwlab-gateway",
"hwlab-gateway-simu",
"hwlab-box-simu",
"hwlab-patch-panel"
],
"routePhases": [
"master-edge-proxy",
"frp-tunnel",
"d601-router",
"cloud-surface",
"agent-runtime",
"sim-and-patch-panel"
],
"healthTimestamp": "2026-05-21T00:00:00.000Z"
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -793,7 +793,7 @@ function dockerfile(baseImage, port) {
"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",
"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 --include=optional --ignore-scripts; else npm install --omit=dev --include=optional --ignore-scripts; fi && codex_version=\"$(node -p \"require('./node_modules/@openai/codex/package.json').version\")\" && case \"$(uname -m)\" in x86_64|amd64) test -e node_modules/@openai/codex-linux-x64 || npm install --omit=dev --include=optional --ignore-scripts \"@openai/codex-linux-x64@npm:@openai/codex@${codex_version}-linux-x64\" ;; aarch64|arm64) test -e node_modules/@openai/codex-linux-arm64 || npm install --omit=dev --include=optional --ignore-scripts \"@openai/codex-linux-arm64@npm:@openai/codex@${codex_version}-linux-arm64\" ;; esac",
"COPY internal ./internal",
"COPY cmd ./cmd",
"COPY scripts ./scripts",
@@ -1331,7 +1331,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
"docs/dev-acceptance-matrix.md",
"docs/m0-contract-audit.md",
"docs/artifact-catalog.md",
"docs/dev-artifact-publish.md",
"docs/reference/g14-gitops-cicd.md",
"docs/dev-base-image-preflight.md"
],
summary: "DEV artifact publish evidence for pikasTech/HWLAB#35, stored in the DEV gate report envelope."
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env node
import { formatDevCdApplyFailure, runDevCdApply } from "./src/dev-cd-apply.mjs";
try {
process.exitCode = await runDevCdApply(process.argv.slice(2));
} catch (error) {
process.stdout.write(`${JSON.stringify(formatDevCdApplyFailure(error), null, 2)}\n`);
process.exitCode = 1;
}
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env node
import { formatFailure, runDevDeployApply } from "./src/dev-deploy-apply.mjs";
try {
process.exitCode = await runDevDeployApply(process.argv.slice(2));
} catch (error) {
process.stdout.write(`${JSON.stringify(formatFailure(error), null, 2)}\n`);
process.exitCode = 1;
}
-475
View File
@@ -1,475 +0,0 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs";
import {
DEV_ENDPOINT,
DEV_FRONTEND_ENDPOINT,
ENVIRONMENT_DEV,
SERVICE_IDS
} from "../internal/protocol/index.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const fixturePath = path.join(repoRoot, "fixtures/dev-deploy-smoke/dev-deploy-smoke.json");
const defaultActiveReportPath = tempReportPath("dev-m2-deploy-smoke-active.json");
const legacyPublicEndpoint = "http://74.48.78.17:6667";
const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const digestPattern = /^(sha256:[a-f0-9]{64}|not_applicable)$/;
const tagPattern = /^m2-dev-deploy-smoke-[a-f0-9]{7}$/;
const allowedFailureClassifications = new Set([
"network_blocker",
"runtime_blocker",
"agent_blocker",
"observability_blocker",
"safety_blocker"
]);
function parseArgs(argv) {
const flags = new Set();
const options = {
reportPath: defaultActiveReportPath,
writeReport: false,
timeoutMs: 5000
};
const rest = [];
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--report") {
flags.add(arg);
index += 1;
assert.ok(argv[index], "--report requires a path");
options.reportPath = argv[index];
options.writeReport = true;
} else if (arg === "--timeout-ms") {
flags.add(arg);
index += 1;
options.timeoutMs = Number.parseInt(argv[index], 10);
assert.ok(Number.isInteger(options.timeoutMs) && options.timeoutMs > 0, "--timeout-ms must be positive");
} else if (arg.startsWith("--")) {
flags.add(arg);
} else {
rest.push(arg);
}
}
return { flags, options, rest };
}
function loadFixture() {
return readFile(fixturePath, "utf8").then((raw) => JSON.parse(raw));
}
function assertTimestamp(value, context) {
assert.equal(typeof value, "string", `${context} must be a string`);
assert.match(value, timestampPattern, `${context} must be an RFC 3339 UTC timestamp`);
}
function assertServiceContract(entry) {
assert.ok(SERVICE_IDS.includes(entry.serviceId), `unknown serviceId ${entry.serviceId}`);
assert.equal(entry.deployEnv, ENVIRONMENT_DEV, `${entry.serviceId} deployEnv must be dev`);
assert.ok(!Object.hasOwn(entry, "endpoint"), `${entry.serviceId} service contract must not carry endpoint`);
assert.equal(typeof entry.commitId, "string", `${entry.serviceId} commitId must be a string`);
assert.match(entry.commitId, /^[a-f0-9]{7}$/u, `${entry.serviceId} commitId must be a short sha`);
assert.equal(typeof entry.image, "string", `${entry.serviceId} image must be a string`);
assert.ok(entry.image.length > 0, `${entry.serviceId} image must not be empty`);
assert.equal(typeof entry.tag, "string", `${entry.serviceId} tag must be a string`);
assert.match(entry.tag, tagPattern, `${entry.serviceId} tag must encode the smoke commit`);
assert.equal(typeof entry.digest, "string", `${entry.serviceId} digest must be a string`);
assert.match(entry.digest, digestPattern, `${entry.serviceId} digest must be sha256 or not_applicable`);
if (entry.digest === "not_applicable") {
assert.equal(typeof entry.digestReason, "string", `${entry.serviceId} digestReason required when digest is not_applicable`);
assert.ok(entry.digestReason.length > 0, `${entry.serviceId} digestReason must not be empty`);
} else {
assert.equal(entry.digestReason, undefined, `${entry.serviceId} digestReason must be absent when digest is present`);
}
assert.equal(typeof entry.buildSource, "string", `${entry.serviceId} buildSource must be a string`);
assert.ok(entry.buildSource.includes("origin/main"), `${entry.serviceId} buildSource must point at origin/main`);
assertTimestamp(entry.healthTimestamp, `${entry.serviceId} healthTimestamp`);
assert.equal(typeof entry.routePhase, "number", `${entry.serviceId} routePhase must be numeric`);
assert.ok(entry.routePhase > 0, `${entry.serviceId} routePhase must be positive`);
assert.ok(entry.phase.length > 0, `${entry.serviceId} phase must not be empty`);
assert.equal(typeof entry.health, "object", `${entry.serviceId} health must be an object`);
assert.ok(entry.health && !Array.isArray(entry.health), `${entry.serviceId} health must be a plain object`);
assert.ok(typeof entry.health.status === "string", `${entry.serviceId} health.status must be a string`);
assert.equal(typeof entry.failureClassification, "string", `${entry.serviceId} failureClassification must be a string`);
assert.ok(entry.failureClassification.endsWith("_blocker"), `${entry.serviceId} failureClassification must end in _blocker`);
assert.ok(
allowedFailureClassifications.has(entry.failureClassification),
`${entry.serviceId} failureClassification must be one of the frozen blocker classes`
);
}
function assertRoutePhaseOrder(entries) {
const phases = entries.map((entry) => entry.routePhase);
assert.deepEqual(phases, [...phases].sort((a, b) => a - b), "route phases must be sorted");
assert.equal(new Set(phases).size, phases.length, "route phases must be unique");
}
function assertAllServiceIds(entries) {
const serviceIds = entries.map((entry) => entry.serviceId);
assert.equal(new Set(serviceIds).size, serviceIds.length, "service ids must be unique in fixture");
for (const serviceId of serviceIds) {
assert.ok(SERVICE_IDS.includes(serviceId), `fixture uses unknown serviceId ${serviceId}`);
}
}
function assertCloudSurface(entry) {
const healthArtifacts = entry.health.artifacts ?? [];
assert.equal(entry.serviceId, "hwlab-cloud-api", "cloud surface entry must start at cloud-api");
assert.equal(entry.health.db?.fixtureOnly, true, "cloud surface DB note must be fixture-only");
assert.equal(entry.health.db?.liveDbEvidence, false, "cloud surface fixture must not claim live DB evidence");
assert.deepEqual(
entry.health.db?.missingEnv,
["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"],
"cloud surface fixture must name missing DB env without values"
);
assert.equal(healthArtifacts.length, 2, "cloud surface must include cloud-api and cloud-web artifacts");
assert.deepEqual(
healthArtifacts.map((artifact) => artifact.serviceId),
["hwlab-cloud-api", "hwlab-cloud-web"],
"cloud surface artifacts must list api/web"
);
for (const artifact of healthArtifacts) {
assert.ok(SERVICE_IDS.includes(artifact.serviceId), `cloud surface artifact ${artifact.serviceId} must be known`);
assert.equal(typeof artifact.image, "string", `cloud surface artifact ${artifact.serviceId} image must exist`);
assert.equal(typeof artifact.tag, "string", `cloud surface artifact ${artifact.serviceId} tag must exist`);
assert.match(artifact.tag, tagPattern, `cloud surface artifact ${artifact.serviceId} tag must encode the smoke commit`);
assert.match(artifact.digest, /^sha256:[a-f0-9]{64}$/u, `cloud surface artifact ${artifact.serviceId} digest must be sha256`);
assert.equal(typeof artifact.commitId, "string", `cloud surface artifact ${artifact.serviceId} commitId must exist`);
assert.equal(typeof artifact.buildSource, "string", `cloud surface artifact ${artifact.serviceId} buildSource must exist`);
assert.equal(artifact.deployEnv, ENVIRONMENT_DEV, `cloud surface artifact ${artifact.serviceId} deployEnv must be dev`);
assertTimestamp(artifact.healthTimestamp ?? entry.healthTimestamp, `cloud surface artifact ${artifact.serviceId} healthTimestamp`);
}
}
function assertFixture(fixture) {
assert.equal(fixture.smokeId, "m2-dev-deploy-smoke");
assert.equal(fixture.issue, "pikasTech/HWLAB#23");
assert.equal(fixture.environment, ENVIRONMENT_DEV);
assert.equal(fixture.endpoint, DEV_ENDPOINT);
assert.equal(fixture.frontendEndpoint, DEV_FRONTEND_ENDPOINT);
assert.equal(fixture.legacyPublicEndpoint?.endpoint, legacyPublicEndpoint);
assert.equal(fixture.legacyPublicEndpoint?.activeGreenEligible, false);
assert.equal(fixture.defaultMode, "dry-run");
assert.equal(fixture.safetyGates.dryRunOnly, true, "dry-run must be the default");
assert.ok(Array.isArray(fixture.safetyGates.realRequestsRequire), "real request gates must exist");
assert.ok(fixture.safetyGates.realRequestsRequire.includes("explicit --live flag"));
const entries = fixture.serviceContracts;
assert.ok(Array.isArray(entries) && entries.length >= 8, "serviceContracts must contain route observations");
assertAllServiceIds(entries);
assertRoutePhaseOrder(entries);
for (const entry of entries) {
assertServiceContract(entry);
}
const cloudSurface = entries.find((entry) => entry.phase === "cloud-surface");
assert.ok(cloudSurface, "cloud-surface entry must exist");
assertCloudSurface(cloudSurface);
const agentSkills = entries.find((entry) => entry.serviceId === "hwlab-agent-skills");
assert.equal(agentSkills.digest, "not_applicable");
assert.equal(agentSkills.digestReason, "local dry-run fixture for repo-local skills bundle");
const observations = fixture.observations;
const expectedObservationIds = [];
for (const entry of entries) {
expectedObservationIds.push(entry.serviceId);
if (entry.serviceId === "hwlab-cloud-api") {
expectedObservationIds.push("hwlab-cloud-web");
}
}
assert.deepEqual(observations.serviceIds, expectedObservationIds, "observation service ids must track validated entries");
assert.deepEqual(
observations.routePhases,
["master-edge-proxy", "frp-tunnel", "d601-router", "cloud-surface", "agent-runtime", "sim-and-patch-panel"],
"route phase labels must stay frozen"
);
assertTimestamp(observations.healthTimestamp, "observations.healthTimestamp");
}
function redactedHeaders(headers) {
return Object.fromEntries(
["content-type", "content-length", "cache-control", "date"].flatMap((name) => {
const value = headers.get(name);
return value ? [[name, value]] : [];
})
);
}
async function probeHttp({ id, url, expect }) {
const startedAt = Date.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), expect.timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
const body = await response.text();
const contentType = response.headers.get("content-type") ?? "";
let json = null;
if (contentType.includes("application/json")) {
try {
json = JSON.parse(body);
} catch {
json = null;
}
}
return {
id,
url,
ok: response.ok,
status: response.status,
statusText: response.statusText,
durationMs: Date.now() - startedAt,
contentType,
headers: redactedHeaders(response.headers),
json,
title: body.match(/<title>([^<]+)<\/title>/iu)?.[1] ?? null,
bodyPreview: body.slice(0, 512)
};
} catch (error) {
return {
id,
url,
ok: false,
status: null,
statusText: null,
durationMs: Date.now() - startedAt,
contentType: null,
headers: {},
json: null,
title: null,
bodyPreview: "",
error: error?.name === "AbortError" ? `timeout after ${expect.timeoutMs}ms` : String(error?.message ?? error)
};
} finally {
clearTimeout(timeout);
}
}
function acceptedProbe(probe) {
if (!probe.ok) return false;
if (probe.id === "frontend-root") {
return probe.contentType?.includes("text/html") && probe.title === "HWLAB DEV MVP Gate";
}
return Boolean(
probe.json &&
SERVICE_IDS.includes(probe.json.serviceId) &&
probe.json.environment === ENVIRONMENT_DEV &&
(probe.json.endpoint === undefined || probe.json.endpoint === DEV_ENDPOINT)
);
}
function probeEvidenceLine(probe) {
const accepted = acceptedProbe(probe) ? "yes" : "no";
const identity = probe.json?.serviceId ?? probe.title ?? "unknown";
return `${probe.id}: HTTP ${probe.status ?? "none"} accepted=${accepted} identity=${identity} url=${probe.url}`;
}
async function gitCommitId() {
try {
const result = await execFileAsync("git", ["rev-parse", "--short=12", "HEAD"], {
cwd: repoRoot,
timeout: 5000
});
return result.stdout.trim();
} catch {
return "unknown";
}
}
function buildActiveReport({ fixture, probes, generatedAt, commitId }) {
const passed = probes.every(acceptedProbe);
const status = passed ? "pass" : "blocked";
const commands = [
"curl -fsS --max-time 10 http://74.48.78.17:16667/health",
"curl -fsS --max-time 10 http://74.48.78.17:16667/health/live",
"curl -fsS --max-time 10 http://74.48.78.17:16666/"
];
return {
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
$id: "https://hwlab.pikastech.local/dev-gate/dev-m2-deploy-smoke-active.json",
reportVersion: "v1",
issue: "pikasTech/HWLAB#23",
taskId: "m2-dev-deploy-smoke",
commitId,
acceptanceLevel: "dev_m2_deploy_smoke",
devOnly: true,
prodDisabled: true,
reportLifecycle: activeReportLifecycle(
"Active M2 read-only public endpoint smoke; route reachability is not M5 DEV-LIVE acceptance."
),
status,
generatedAt,
endpoint: DEV_ENDPOINT,
frontendEndpoint: DEV_FRONTEND_ENDPOINT,
sourceContract: {
status: "pass",
documents: [
"docs/m2-dev-deploy-smoke.md",
"docs/dev-acceptance-matrix.md",
"fixtures/dev-deploy-smoke/dev-deploy-smoke.json"
],
summary: "M2 DEV deploy smoke is rebaselined to frontend :16666 and API/edge :16667."
},
validationCommands: [
"node --check scripts/m2-dev-deploy-smoke.mjs",
"node scripts/m2-dev-deploy-smoke.mjs --dry-run",
`node scripts/m2-dev-deploy-smoke.mjs --live --confirm-dev --confirmed-non-production --report ${defaultActiveReportPath}`,
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs"
],
localSmoke: {
status: "pass",
commands: ["node scripts/m2-dev-deploy-smoke.mjs --dry-run"],
evidence: [
`[m2-smoke] validated ${fixture.serviceContracts.length} service contracts`,
`[m2-smoke] mode=dry-run endpoint=${DEV_ENDPOINT}`,
"[m2-smoke] no real DEV/PROD request was made"
],
summary: "The fixture smoke validates the M2 route and artifact contract without contacting DEV."
},
dryRun: {
status: "pass",
commands: ["node scripts/m2-dev-deploy-smoke.mjs --dry-run"],
evidence: [
`fixture endpoint=${DEV_ENDPOINT}`,
`fixture frontendEndpoint=${DEV_FRONTEND_ENDPOINT}`,
"legacy :6667 public evidence is marked ineligible for active green"
],
summary: "Dry-run evidence is contract-only and pinned to the current DEV public endpoints."
},
devPreconditions: {
status,
requirements: [
"GET http://74.48.78.17:16667/health returns HWLAB DEV JSON",
"GET http://74.48.78.17:16667/health/live returns HWLAB DEV JSON",
"GET http://74.48.78.17:16666/ returns the HWLAB DEV frontend",
"No PROD, secret read, runtime restart, deployment, or heavyweight e2e action is performed"
],
commands,
evidence: probes.map(probeEvidenceLine),
summary: passed
? "Read-only DEV public endpoint probes passed on :16666/:16667."
: "One or more read-only DEV public endpoint probes failed; no active green is claimed."
},
blockers: passed
? []
: [
{
type: "network_blocker",
scope: "m2-public-endpoints",
status: "open",
summary: "One or more M2 DEV public endpoint probes failed on :16666/:16667."
}
],
runtimeSmoke: {
mode: "live-read-only",
status,
generatedAt,
apiEndpoint: DEV_ENDPOINT,
frontendEndpoint: DEV_FRONTEND_ENDPOINT,
safety: {
environment: ENVIRONMENT_DEV,
prodTouched: false,
secretsRead: false,
restarts: false,
deployAttempted: false,
heavyE2E: false,
unideskRuntimeSubstitute: false
},
probes,
legacyPublicEndpoint: {
endpoint: legacyPublicEndpoint,
status: "deprecated",
activeGreenEligible: false,
summary: "Legacy public :6667 observations are retained only as historical blockers and are not accepted as active green evidence."
}
},
notes: "M2 active report uses read-only GET probes only; PROD, service restarts, deployments, and script-driven apply flows were not touched."
};
}
async function writeActiveReport(report, reportPath) {
const absoluteReportPath = ensureNotRepoReportsPath(repoRoot, reportPath, "--report");
await mkdir(path.dirname(absoluteReportPath), { recursive: true });
await writeFile(absoluteReportPath, `${JSON.stringify(report, null, 2)}\n`);
}
async function runLiveSmoke(fixture, options) {
const probes = await Promise.all([
probeHttp({
id: "api-health",
url: new URL("/health", DEV_ENDPOINT).href,
expect: { timeoutMs: options.timeoutMs }
}),
probeHttp({
id: "api-live",
url: new URL("/health/live", DEV_ENDPOINT).href,
expect: { timeoutMs: options.timeoutMs }
}),
probeHttp({
id: "frontend-root",
url: new URL("/", DEV_FRONTEND_ENDPOINT).href,
expect: { timeoutMs: options.timeoutMs }
})
]);
const report = buildActiveReport({
fixture,
probes,
generatedAt: new Date().toISOString(),
commitId: await gitCommitId()
});
return report;
}
async function main() {
const { flags, options } = parseArgs(process.argv.slice(2));
const isLive = flags.has("--live") || flags.has("--allow-live");
if (isLive) {
assert.ok(!flags.has("--dry-run"), "do not combine --live with --dry-run");
assert.ok(flags.has("--confirm-dev"), "live mode requires --confirm-dev");
assert.ok(flags.has("--confirmed-non-production"), "live mode requires --confirmed-non-production");
assert.ok(!flags.has("--prod"), "PROD requests are forbidden");
assert.ok(!flags.has("--heavyweight-e2e"), "heavyweight e2e is forbidden");
assert.ok(!flags.has("--read-secret"), "secret reads are forbidden");
assert.ok(!flags.has("--force-push"), "force push is forbidden");
}
const fixture = await loadFixture();
assertFixture(fixture);
assert.equal(fixture.endpoint, DEV_ENDPOINT, "fixture must stay pinned to the frozen DEV endpoint");
process.stdout.write(`[m2-smoke] validated ${fixture.serviceContracts.length} service contracts\n`);
process.stdout.write(`[m2-smoke] mode=${isLive ? "live" : "dry-run"} endpoint=${fixture.endpoint}\n`);
if (isLive) {
const report = await runLiveSmoke(fixture, options);
if (options.writeReport) {
await writeActiveReport(report, options.reportPath);
process.stdout.write(`[m2-smoke] wrote ${path.relative(repoRoot, options.reportPath)}\n`);
}
process.stdout.write(`[m2-smoke] active-report=${report.status} probes=${report.runtimeSmoke.probes.length}\n`);
process.stdout.write("[m2-smoke] no DEV/PROD mutation was made\n");
if (report.status !== "pass") {
process.exitCode = 2;
}
} else {
process.stdout.write("[m2-smoke] no real DEV/PROD request was made\n");
}
}
try {
await main();
} catch (error) {
process.stderr.write(`[m2-smoke] ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
+1 -1
View File
@@ -31,7 +31,7 @@ test("refresh accepts an absolute publish report path", async () => {
const reportPath = path.join(tempDir, "dev-artifacts.json");
await writeFile(reportPath, `${JSON.stringify({
reportVersion: "v1",
taskId: "dev-artifact-publish",
taskId: "g14-artifact-publish",
commitId: shortCommitId,
artifactPublish: {
status: "published",
@@ -260,9 +260,7 @@ export function buildArtifactRuntimeReadinessReport({
sourceContract: {
status: "pass",
documents: [
"docs/dev-artifact-publish.md",
"docs/dev-deploy-apply.md",
"docs/reference/deployment-publish.md"
"docs/reference/g14-gitops-cicd.md"
],
summary: "Target commit, artifact publish/catalog identity, desired-state convergence, API runtime identity, and Cloud Web served runtime identity are separate gates."
},
@@ -1172,7 +1170,7 @@ function nextCommands({ canPublish, canApply, blockedReasons }) {
return [
"node web/hwlab-cloud-web/scripts/build.mjs",
"node web/hwlab-cloud-web/scripts/check.mjs",
"node scripts/dev-artifact-publish.mjs --preflight --no-report",
"node scripts/g14-artifact-publish.mjs --preflight --no-report",
"node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked --no-write",
guardCommand
];
@@ -1181,7 +1179,7 @@ function nextCommands({ canPublish, canApply, blockedReasons }) {
return [
"node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --pretty",
"node scripts/deploy-desired-state-plan.mjs --promotion-commit <origin-main-sha> --check",
`node scripts/dev-deploy-apply.mjs --dry-run --report ${tempReportPath("dev-deploy-report.json")}`,
"node scripts/g14-gitops-render.mjs --check",
guardCommand
];
}
@@ -1194,7 +1192,7 @@ function nextCommands({ canPublish, canApply, blockedReasons }) {
];
}
return [
`node scripts/dev-deploy-apply.mjs --dry-run --report ${tempReportPath("dev-deploy-report.json")}`,
"node scripts/g14-gitops-render.mjs --check",
"node scripts/artifact-runtime-readiness-guard.mjs --target-ref origin/main --check --live --no-report"
];
}
+1 -1
View File
@@ -67,7 +67,7 @@ function provisionPlan({ exactBlocker = null, envValue = recommendedNode20Image,
`${envVarName}=${recommendedNode20Image} node scripts/preflight-dev-base-image.mjs`
],
artifactPublishDryRun: [
`${envVarName}=${envValue} node scripts/dev-artifact-publish.mjs --preflight --no-report`
`${envVarName}=${envValue} node scripts/g14-artifact-publish.mjs --preflight --no-report`
]
}
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-122
View File
@@ -1,122 +0,0 @@
import { tempReportPath } from "./report-paths.mjs";
const transactionEnvNames = [
"HWLAB_CD_TRANSACTION_ID",
"HWLAB_CD_TRANSACTION_OWNER",
"HWLAB_CD_LOCK_NAME"
];
const ciArtifactIdentityEnvNames = [
"HWLAB_CI_ARTIFACT_RUN_ID",
"GITHUB_RUN_ID",
"CI_PIPELINE_ID",
"BUILDKITE_BUILD_ID"
];
const artifactPublishIdentityEnvNames = [
...ciArtifactIdentityEnvNames,
...transactionEnvNames
];
export function hasDevCdTransactionEnv(env = process.env) {
return typeof env.HWLAB_CD_TRANSACTION_ID === "string" && env.HWLAB_CD_TRANSACTION_ID.trim().length > 0;
}
export function hasCiArtifactPublishEnv(env = process.env) {
return ciArtifactIdentityEnvNames.some((name) => typeof env[name] === "string" && env[name].trim().length > 0);
}
export function hasDevCiArtifactEnv(env = process.env) {
return hasCiArtifactPublishEnv(env);
}
export function hasDevArtifactSideEffectEnv(env = process.env) {
return hasDevCdTransactionEnv(env) || hasCiArtifactPublishEnv(env);
}
export function devArtifactProducer(env = process.env) {
if (hasCiArtifactPublishEnv(env)) {
const idName = ciArtifactIdentityEnvNames.find((name) => typeof env[name] === "string" && env[name].trim().length > 0);
return {
kind: "ci-artifact",
runId: env[idName],
owner: env.HWLAB_CI_ARTIFACT_RUN_OWNER ?? idName ?? 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,
requiredEnv = "HWLAB_CD_TRANSACTION_ID"
}) {
return {
ok: false,
status: "blocked",
error: "cd-transaction-required",
code: "cd-transaction-required",
script,
mode,
requiredEnv,
acceptedEnv: transactionEnvNames,
entrypoint: `node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --report ${tempReportPath("dev-cd-apply.json")}`,
summary: `${script} ${mode} is a DEV CD side-effect step and must run inside scripts/dev-cd-apply.mjs.`,
devOnly: true,
prodTouched: false,
mutationAttempted: false
};
}
export function devArtifactPublishIdentityGuardFailure({
script,
mode,
requiredEnv = "HWLAB_CI_ARTIFACT_RUN_ID or HWLAB_CD_TRANSACTION_ID"
}) {
return {
ok: false,
status: "blocked",
error: "artifact-publish-identity-required",
code: "artifact-publish-identity-required",
script,
mode,
requiredEnv,
acceptedEnv: artifactPublishIdentityEnvNames,
entrypoint: `HWLAB_CI_ARTIFACT_RUN_ID=<run-id> node scripts/artifact-publish.mjs --publish --report ${tempReportPath("dev-artifacts.json")}`,
summary: `${script} ${mode} is an artifact publish side-effect step and must run under CI artifact identity.`,
devOnly: true,
prodTouched: false,
cdLockRequired: false,
rolloutAttempted: false,
mutationAttempted: false
};
}
export function devArtifactSideEffectGuardFailure(options) {
return devArtifactPublishIdentityGuardFailure(options);
}
export function requireDevCdTransactionForSideEffect({ env = process.env, script, mode }) {
if (hasDevCdTransactionEnv(env)) return null;
return devCdTransactionGuardFailure({ script, mode });
}
export function requireDevArtifactPublishIdentity({ env = process.env, script, mode }) {
if (hasCiArtifactPublishEnv(env) || hasDevCdTransactionEnv(env)) return null;
return devArtifactPublishIdentityGuardFailure({ script, mode });
}
export function requireDevArtifactSideEffectEnv(options) {
return requireDevArtifactPublishIdentity(options);
}
@@ -488,7 +488,7 @@ function runStaticSmoke() {
const blockers = [];
const files = readStaticFiles();
const source = Object.values(files).join("\n");
const artifactPublisher = readText("scripts/dev-artifact-publish.mjs");
const artifactPublisher = readText("scripts/artifact-publish.mjs");
const buildScript = readText("web/hwlab-cloud-web/scripts/build.mjs");
const distContractScript = readText("web/hwlab-cloud-web/scripts/dist-contract.mjs");
@@ -1745,7 +1745,7 @@ function readStaticFiles() {
panel: readText("web/hwlab-cloud-web/workbench-hardware-panel.mjs"),
codeAgentM3Evidence: readText("web/hwlab-cloud-web/code-agent-m3-evidence.mjs"),
help: readText("web/hwlab-cloud-web/help.md"),
artifactPublisher: readText("scripts/dev-artifact-publish.mjs")
artifactPublisher: readText("scripts/artifact-publish.mjs")
};
}
File diff suppressed because it is too large Load Diff
-648
View File
@@ -1,648 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildKubectlCommandPrefix,
compareRuntimeIdentityEnv,
decideDevLegacySimulatorDeploymentCleanup,
decideDevTemplateJobReplacement,
decideDevUnmanagedHotfixDeploymentCleanup,
formatKubeconfigAccessFailure,
inspectCodeAgentProviderDesiredState,
inspectCodeAgentProviderLiveDeployment,
parseArgs,
redactSensitiveText,
registryManifestUrlForImage,
resolveApplySourceCommit,
resolveDevKubeconfigSelection,
verifyCatalogRegistryManifest
} from "./dev-deploy-apply.mjs";
import {
devArtifactProducer,
devArtifactPublishIdentityGuardFailure,
devCdTransactionGuardFailure,
hasCiArtifactPublishEnv,
hasDevCdTransactionEnv,
requireDevArtifactPublishIdentity,
requireDevCdTransactionForSideEffect
} from "./dev-cd-transaction-guard.mjs";
const codeAgentBaseUrl = "http://172.26.26.227:17680/v1/responses";
function codeAgentDeploy(env = {}) {
return {
services: [
{
serviceId: "hwlab-cloud-api",
env: {
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
HWLAB_CODE_AGENT_MODEL: "gpt-5.5",
HWLAB_CODE_AGENT_OPENAI_BASE_URL: codeAgentBaseUrl,
CODEX_HOME: "/codex-home",
NO_PROXY: "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com",
no_proxy: "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com",
OPENAI_API_KEY: "secretRef:hwlab-code-agent-provider/openai-api-key",
...env
}
}
]
};
}
function codeAgentWorkloads(envOverrides = []) {
return {
kind: "List",
items: [
{
kind: "Deployment",
metadata: {
name: "hwlab-cloud-api",
namespace: "hwlab-dev",
labels: {
"hwlab.pikastech.local/service-id": "hwlab-cloud-api"
}
},
spec: {
template: {
spec: {
containers: [
{
name: "hwlab-cloud-api",
env: [
{ name: "HWLAB_CODE_AGENT_PROVIDER", value: "codex-stdio" },
{ name: "HWLAB_CODE_AGENT_MODEL", value: "gpt-5.5" },
{ name: "HWLAB_CODE_AGENT_OPENAI_BASE_URL", value: codeAgentBaseUrl },
{ name: "CODEX_HOME", value: "/codex-home" },
{ name: "NO_PROXY", value: "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com" },
{ name: "no_proxy", value: "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com" },
{
name: "OPENAI_API_KEY",
valueFrom: {
secretKeyRef: {
name: "hwlab-code-agent-provider",
key: "openai-api-key"
}
}
},
...envOverrides
],
volumeMounts: [
{ name: "hwlab-code-agent-codex-home", mountPath: "/codex-home" },
{ name: "hwlab-code-agent-codex-config", mountPath: "/codex-home/config.toml", subPath: "config.toml", readOnly: true },
{ name: "hwlab-code-agent-codex-auth", mountPath: "/codex-home/auth.json", subPath: "auth.json", readOnly: true }
]
}
],
volumes: [
{ name: "hwlab-code-agent-codex-home", emptyDir: {} },
{ name: "hwlab-code-agent-codex-config", configMap: { name: "hwlab-code-agent-codex-config", items: [{ key: "config.toml", path: "config.toml" }] } },
{ name: "hwlab-code-agent-codex-auth", secret: { secretName: "hwlab-code-agent-codex-auth", items: [{ key: "auth.json", path: "auth.json" }] } }
]
}
}
}
}
]
};
}
test("allowlisted suspended template Job image change plans replacement", () => {
const decision = decideDevTemplateJobReplacement({
jobName: "hwlab-agent-worker-template",
jobNamespace: "hwlab-dev",
desiredSuspended: true,
liveExists: true,
liveSuspended: true,
oldImages: [
{
name: "hwlab-agent-worker",
serviceId: "hwlab-agent-worker",
image: "127.0.0.1:5000/hwlab/hwlab-agent-worker:old1234"
}
],
newImages: [
{
name: "hwlab-agent-worker",
serviceId: "hwlab-agent-worker",
image: "127.0.0.1:5000/hwlab/hwlab-agent-worker:new5678"
}
]
});
assert.equal(decision.replace, true);
assert.equal(decision.result, "planned");
assert.equal(decision.namespace, "hwlab-dev");
assert.equal(decision.jobName, "hwlab-agent-worker-template");
assert.equal(decision.oldImage, "127.0.0.1:5000/hwlab/hwlab-agent-worker:old1234");
assert.equal(decision.newImage, "127.0.0.1:5000/hwlab/hwlab-agent-worker:new5678");
});
test("DEV CD transaction guard rejects direct legacy side-effect calls", () => {
assert.equal(hasDevCdTransactionEnv({}), false);
assert.equal(hasDevCdTransactionEnv({ HWLAB_CD_TRANSACTION_ID: "tx-123" }), true);
const failure = requireDevCdTransactionForSideEffect({
env: {},
script: "scripts/dev-deploy-apply.mjs",
mode: "--apply"
});
assert.deepEqual(failure, devCdTransactionGuardFailure({
script: "scripts/dev-deploy-apply.mjs",
mode: "--apply"
}));
assert.equal(failure.error, "cd-transaction-required");
assert.equal(failure.mutationAttempted, false);
assert.match(failure.entrypoint, /scripts\/dev-cd-apply\.mjs/u);
assert.equal(
requireDevCdTransactionForSideEffect({
env: { HWLAB_CD_TRANSACTION_ID: "tx-123" },
script: "scripts/dev-deploy-apply.mjs",
mode: "--apply"
}),
null
);
});
test("DEV artifact publish guard allows CI identity without CD transaction", () => {
const ciEnv = {
HWLAB_CI_ARTIFACT_RUN_ID: "ci-artifact-123",
HWLAB_CI_ARTIFACT_RUN_OWNER: "tekton"
};
assert.equal(hasCiArtifactPublishEnv({}), false);
assert.equal(hasCiArtifactPublishEnv(ciEnv), true);
assert.equal(requireDevArtifactPublishIdentity({
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 = requireDevArtifactPublishIdentity({
env: {},
script: "scripts/dev-artifact-publish.mjs",
mode: "--publish"
});
assert.deepEqual(failure, devArtifactPublishIdentityGuardFailure({
script: "scripts/dev-artifact-publish.mjs",
mode: "--publish"
}));
assert.equal(failure.error, "artifact-publish-identity-required");
assert.equal(failure.cdLockRequired, false);
assert.equal(failure.rolloutAttempted, false);
assert.equal(failure.mutationAttempted, false);
assert.match(failure.entrypoint, /HWLAB_CI_ARTIFACT_RUN_ID=.*dev-artifact-publish\.mjs/u);
assert.equal(
requireDevArtifactPublishIdentity({
env: { HWLAB_CI_ARTIFACT_RUN_ID: "ci-123" },
script: "scripts/dev-artifact-publish.mjs",
mode: "--publish"
}),
null
);
assert.equal(
requireDevArtifactPublishIdentity({
env: { HWLAB_CD_TRANSACTION_ID: "tx-123" },
script: "scripts/dev-artifact-publish.mjs",
mode: "--publish"
}),
null
);
});
test("matching allowlisted suspended template Job image does not replace", () => {
const image = "127.0.0.1:5000/hwlab/hwlab-cli:73b379f";
const decision = decideDevTemplateJobReplacement({
jobName: "hwlab-cli-template",
jobNamespace: "hwlab-dev",
desiredSuspended: true,
liveExists: true,
liveSuspended: true,
oldImages: [{ name: "hwlab-cli", serviceId: "hwlab-cli", image }],
newImages: [{ name: "hwlab-cli", serviceId: "hwlab-cli", image }]
});
assert.equal(decision.replace, false);
assert.equal(decision.result, "not_needed");
});
test("replacement policy refuses non-allowlisted or active Jobs", () => {
const ignored = decideDevTemplateJobReplacement({
jobName: "some-other-job",
jobNamespace: "hwlab-dev",
desiredSuspended: true,
liveExists: true,
liveSuspended: true,
oldImages: [{ name: "worker", serviceId: "worker", image: "image:old" }],
newImages: [{ name: "worker", serviceId: "worker", image: "image:new" }]
});
assert.equal(ignored.replace, false);
assert.equal(ignored.result, "ignored");
const active = decideDevTemplateJobReplacement({
jobName: "hwlab-cli-template",
jobNamespace: "hwlab-dev",
desiredSuspended: true,
liveExists: true,
liveSuspended: false,
oldImages: [{ name: "hwlab-cli", serviceId: "hwlab-cli", image: "image:old" }],
newImages: [{ name: "hwlab-cli", serviceId: "hwlab-cli", image: "image:new" }]
});
assert.equal(active.replace, false);
assert.equal(active.result, "blocked");
});
test("allowlisted stale simulator Deployment plans cleanup only with StatefulSet replacement", () => {
const decision = decideDevLegacySimulatorDeploymentCleanup({
deploymentName: "hwlab-box-simu",
deploymentNamespace: "hwlab-dev",
desiredReplacementExists: true,
desiredReplacementKind: "StatefulSet",
liveExists: true,
oldImages: [
{
name: "hwlab-box-simu",
serviceId: "hwlab-box-simu",
image: "127.0.0.1:5000/hwlab/hwlab-box-simu:old1234"
}
],
newImages: [
{
name: "hwlab-box-simu",
serviceId: "hwlab-box-simu",
image: "127.0.0.1:5000/hwlab/hwlab-box-simu:new5678"
}
]
});
assert.equal(decision.cleanup, true);
assert.equal(decision.result, "planned");
assert.equal(decision.namespace, "hwlab-dev");
assert.equal(decision.deploymentName, "hwlab-box-simu");
assert.equal(decision.serviceId, "hwlab-box-simu");
assert.equal(decision.desiredReplacementKind, "StatefulSet");
assert.equal(decision.oldImage, "127.0.0.1:5000/hwlab/hwlab-box-simu:old1234");
assert.equal(decision.newImage, "127.0.0.1:5000/hwlab/hwlab-box-simu:new5678");
});
test("legacy simulator cleanup refuses non-DEV or missing replacement cleanup", () => {
const wrongNamespace = decideDevLegacySimulatorDeploymentCleanup({
deploymentName: "hwlab-gateway-simu",
deploymentNamespace: "hwlab-prod",
desiredReplacementExists: true,
desiredReplacementKind: "StatefulSet",
liveExists: true
});
assert.equal(wrongNamespace.cleanup, false);
assert.equal(wrongNamespace.result, "blocked");
const missingReplacement = decideDevLegacySimulatorDeploymentCleanup({
deploymentName: "hwlab-gateway-simu",
deploymentNamespace: "hwlab-dev",
desiredReplacementExists: false,
desiredReplacementKind: null,
liveExists: true
});
assert.equal(missingReplacement.cleanup, false);
assert.equal(missingReplacement.result, "blocked");
const ignored = decideDevLegacySimulatorDeploymentCleanup({
deploymentName: "hwlab-cloud-api",
deploymentNamespace: "hwlab-dev",
desiredReplacementExists: true,
desiredReplacementKind: "StatefulSet",
liveExists: true
});
assert.equal(ignored.cleanup, false);
assert.equal(ignored.result, "ignored");
});
test("unmanaged hotfix Deployment cleanup plans recreation when live has extra hotfix mounts", () => {
const desired = codeAgentWorkloads().items[0];
const live = structuredClone(desired);
live.spec.template.metadata = {
annotations: {
"hwlab.pikastech.local/runtime-hotfix": "2026-05-24"
}
};
live.spec.template.spec.volumes.unshift({
name: "hwlab-code-agent-hotfix-20260524",
configMap: { name: "hwlab-code-agent-hotfix-20260524" }
});
live.spec.template.spec.containers[0].volumeMounts.unshift({
name: "hwlab-code-agent-hotfix-20260524",
mountPath: "/app/internal/cloud/server.mjs",
subPath: "server.mjs",
readOnly: true
});
const decision = decideDevUnmanagedHotfixDeploymentCleanup({
deploymentName: "hwlab-cloud-api",
deploymentNamespace: "hwlab-dev",
desiredDeployment: desired,
liveDeployment: live
});
assert.equal(decision.cleanup, true);
assert.equal(decision.result, "planned");
assert.equal(decision.unmanagedVolumes[0].configMap, "hwlab-code-agent-hotfix-20260524");
assert.equal(decision.unmanagedVolumeMounts[0].mountPath, "/app/internal/cloud/server.mjs");
assert.equal(decision.unmanagedTemplateAnnotations[0].key, "hwlab.pikastech.local/runtime-hotfix");
});
test("unmanaged hotfix Deployment cleanup ignores fields already present in desired source", () => {
const desired = codeAgentWorkloads().items[0];
desired.spec.template.spec.volumes.unshift({
name: "hwlab-code-agent-hotfix-20260524",
configMap: { name: "hwlab-code-agent-hotfix-20260524" }
});
desired.spec.template.spec.containers[0].volumeMounts.unshift({
name: "hwlab-code-agent-hotfix-20260524",
mountPath: "/app/internal/cloud/server.mjs",
subPath: "server.mjs",
readOnly: true
});
const live = structuredClone(desired);
const decision = decideDevUnmanagedHotfixDeploymentCleanup({
deploymentName: "hwlab-cloud-api",
deploymentNamespace: "hwlab-dev",
desiredDeployment: desired,
liveDeployment: live
});
assert.equal(decision.cleanup, false);
assert.equal(decision.result, "not_needed");
});
test("apply source commit follows deploy/catalog artifact identity", () => {
assert.equal(
resolveApplySourceCommit({ commitId: "73b379f" }, { commitId: "73b379f" }, "cb35ada68606"),
"73b379f"
);
assert.equal(
resolveApplySourceCommit({ commitId: "73b379f" }, { commitId: "deadbee" }, "cb35ada68606"),
"cb35ada68606"
);
});
test("runtime identity env comparator accepts matching commit image and tag", () => {
const comparison = compareRuntimeIdentityEnv(
{
HWLAB_COMMIT_ID: "7de6edd",
HWLAB_IMAGE: "127.0.0.1:5000/hwlab/hwlab-cloud-web:7de6edd",
HWLAB_IMAGE_TAG: "7de6edd"
},
{
HWLAB_COMMIT_ID: "7de6edd2c41fb50dcb007353780338be8832b27e",
HWLAB_IMAGE: "127.0.0.1:5000/hwlab/hwlab-cloud-web:7de6edd",
HWLAB_IMAGE_TAG: "7de6edd"
}
);
assert.equal(comparison.status, "match");
assert.equal(comparison.matchesDesired, true);
assert.deepEqual(comparison.drift, []);
});
test("runtime identity env comparator detects stale cloud-web env drift", () => {
const comparison = compareRuntimeIdentityEnv(
{
HWLAB_COMMIT_ID: "c7de474",
HWLAB_IMAGE: "127.0.0.1:5000/hwlab/hwlab-cloud-web:c7de474",
HWLAB_IMAGE_TAG: "c7de474"
},
{
HWLAB_COMMIT_ID: "7de6edd",
HWLAB_IMAGE: "127.0.0.1:5000/hwlab/hwlab-cloud-web:7de6edd",
HWLAB_IMAGE_TAG: "7de6edd"
}
);
assert.equal(comparison.status, "drift");
assert.equal(comparison.matchesDesired, false);
assert.deepEqual(
comparison.drift.map((field) => field.name),
["HWLAB_COMMIT_ID", "HWLAB_IMAGE", "HWLAB_IMAGE_TAG"]
);
});
test("Code Agent provider desired state preserves Secret ref and egress base URL", () => {
const inspection = inspectCodeAgentProviderDesiredState(codeAgentDeploy(), codeAgentWorkloads());
assert.equal(inspection.ready, true);
assert.equal(inspection.status, "pass");
assert.equal(inspection.provider, "codex-stdio");
assert.equal(inspection.runtimeProvider, "openai-responses");
assert.equal(inspection.backend, "hwlab-cloud-api/codex-app-server-stdio");
assert.equal(inspection.secretRef.present, true);
assert.equal(inspection.secretMaterialRead, false);
assert.equal(inspection.egress.deployMatchesDevProxy, true);
assert.equal(inspection.egress.workloadMatchesDevProxy, true);
assert.equal(inspection.codexHome.present, true);
assert.equal(inspection.codexHome.configMapPresent, true);
assert.equal(inspection.codexHome.authSecretPresent, true);
});
test("Code Agent provider desired state blocks direct public OpenAI base URL", () => {
const inspection = inspectCodeAgentProviderDesiredState(
codeAgentDeploy({ HWLAB_CODE_AGENT_OPENAI_BASE_URL: "https://api.openai.com/v1/responses" }),
codeAgentWorkloads([
{ name: "HWLAB_CODE_AGENT_OPENAI_BASE_URL", value: "https://api.openai.com/v1/responses" }
])
);
assert.equal(inspection.ready, false);
assert.equal(inspection.status, "blocked");
assert.equal(inspection.egress.directPublicOpenAi, true);
assert.ok(inspection.missingEgressContract.some((entry) => entry.includes("api.openai.com")));
});
test("Code Agent live Deployment inspection reads only env metadata", () => {
const deployment = codeAgentWorkloads().items[0];
const inspection = inspectCodeAgentProviderLiveDeployment(deployment);
assert.equal(inspection.ready, true);
assert.equal(inspection.secretRef.present, true);
assert.equal(inspection.secretValuesRead, false);
assert.equal(inspection.kubernetesSecretDataRead, false);
assert.equal(inspection.validationScope, "deployment-env-and-secretKeyRef-metadata-only");
});
test("DEV kubeconfig selection prefers flag then HWLAB_DEV_KUBECONFIG then KUBECONFIG then D601 default", () => {
assert.deepEqual(
resolveDevKubeconfigSelection({
flagValue: "/tmp/from-flag",
env: {
HWLAB_DEV_KUBECONFIG: "/tmp/from-hwlab",
KUBECONFIG: "/tmp/from-kubeconfig"
}
}),
{
kubeconfig: "/tmp/from-flag",
source: "flag:--kubeconfig",
paths: ["/tmp/from-flag"]
}
);
assert.deepEqual(
resolveDevKubeconfigSelection({
env: {
HWLAB_DEV_KUBECONFIG: "/tmp/from-hwlab",
KUBECONFIG: "/tmp/from-kubeconfig"
}
}),
{
kubeconfig: "/tmp/from-hwlab",
source: "env:HWLAB_DEV_KUBECONFIG",
paths: ["/tmp/from-hwlab"]
}
);
assert.deepEqual(
resolveDevKubeconfigSelection({
env: {
KUBECONFIG: "/tmp/from-kubeconfig"
}
}),
{
kubeconfig: "/tmp/from-kubeconfig",
source: "env:KUBECONFIG",
paths: ["/tmp/from-kubeconfig"]
}
);
assert.deepEqual(
resolveDevKubeconfigSelection({ env: {} }),
{
kubeconfig: "/etc/rancher/k3s/k3s.yaml",
source: "default:d601-k3s",
paths: ["/etc/rancher/k3s/k3s.yaml"]
}
);
});
test("DEV kubeconfig selection keeps multi-path KUBECONFIG readable checks explicit", () => {
const delimiter = process.platform === "win32" ? ";" : ":";
const selection = resolveDevKubeconfigSelection({
env: {
KUBECONFIG: [`/tmp/one`, `/tmp/two`].join(delimiter)
}
});
assert.equal(selection.kubeconfig, [`/tmp/one`, `/tmp/two`].join(delimiter));
assert.equal(selection.source, "env:KUBECONFIG");
assert.deepEqual(selection.paths, ["/tmp/one", "/tmp/two"]);
});
test("kubectl command prefix quotes kubeconfig paths without exposing contents", () => {
assert.equal(
buildKubectlCommandPrefix("/var/lib/unidesk/code-queue/kubeconfig"),
"KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl"
);
assert.equal(
buildKubectlCommandPrefix("/tmp/path with space/kubeconfig"),
"KUBECONFIG='/tmp/path with space/kubeconfig' kubectl"
);
});
test("kubeconfig access failure names source and path without secret material", () => {
const message = formatKubeconfigAccessFailure(
{
source: "env:HWLAB_DEV_KUBECONFIG"
},
"/missing/kubeconfig"
);
assert.match(message, /env:HWLAB_DEV_KUBECONFIG/u);
assert.match(message, /\/missing\/kubeconfig/u);
assert.match(message, /Secret values are never printed/u);
assert.equal(message.includes("client-key-data:"), false);
});
test("parseArgs reports a missing kubeconfig path as a blocking error", () => {
const args = parseArgs(["--apply", "--kubeconfig"]);
assert.equal(args.apply, true);
assert.equal(args.kubeconfigSpecified, true);
assert.deepEqual(args.errors, ["--kubeconfig requires a non-empty path value"]);
});
test("registry manifest URL can be redirected without changing deploy image identity", () => {
const parsed = registryManifestUrlForImage("127.0.0.1:5000/hwlab/hwlab-cloud-api:abc1234", {
registryManifestBaseUrl: "http://hwlab-registry-proxy.hwlab-dev.svc.cluster.local:5000/"
});
assert.equal(parsed.registry, "127.0.0.1:5000");
assert.equal(parsed.repository, "hwlab/hwlab-cloud-api");
assert.equal(parsed.url, "http://hwlab-registry-proxy.hwlab-dev.svc.cluster.local:5000/v2/hwlab/hwlab-cloud-api/manifests/abc1234");
assert.equal(parsed.registryAccess.source, "flag:--registry-manifest-base-url");
});
test("registry manifest verification falls back to docker host-network helper when pod loopback is blocked", async () => {
const expectedDigest = "sha256:2cc48ca2bd0e260d7ea6df1e1a791df6615a87da3d89f4b11103735a1e2d9b63";
const blockers = [];
const commands = [];
const result = await verifyCatalogRegistryManifest(
{
serviceId: "hwlab-cloud-api",
image: "127.0.0.1:5000/hwlab/hwlab-cloud-api:fad9fc9",
imageTag: "fad9fc9",
digest: expectedDigest
},
blockers,
{
env: {
HWLAB_DEV_REGISTRY_HOSTNET_HELPER_IMAGE: "helper:local"
},
httpRequestText: async () => {
throw new Error("connect ECONNREFUSED 127.0.0.1:5000");
},
runCommand: async (command, args) => {
commands.push([command, args]);
return {
ok: true,
code: 0,
stdout: JSON.stringify({
statusCode: 200,
headers: { "docker-content-digest": expectedDigest }
}),
stderr: ""
};
}
}
);
assert.equal(result.status, "pass");
assert.equal(result.registryAccess.mode, "docker-host-network");
assert.equal(result.registryAccess.helperImage, "helper:local");
assert.match(result.registryAccess.directError, /ECONNREFUSED/u);
assert.deepEqual(blockers, []);
assert.equal(commands[0][0], "docker");
assert.deepEqual(commands[0][1].slice(0, 6), ["run", "--rm", "--pull=never", "--network", "host", "helper:local"]);
});
test("redaction removes common token and password material from kubectl output", () => {
const output = redactSensitiveText(
[
"Authorization: Bearer abc.def-123",
"token: abcdef123456",
"password=plain-secret",
"client-key-data: LS0tPRIVATE",
"postgresql://hwlab:db-secret@example.invalid:5432/hwlab"
].join("\n")
);
assert.equal(output.includes("abc.def-123"), false);
assert.equal(output.includes("plain-secret"), false);
assert.equal(output.includes("LS0tPRIVATE"), false);
assert.equal(output.includes("db-secret"), false);
assert.match(output, /Bearer <redacted>/u);
assert.match(output, /password=<redacted>/u);
assert.match(output, /client-key-data: <redacted>/u);
assert.match(output, /postgresql:\/\/hwlab:<redacted>@example.invalid/u);
});
+6 -6
View File
@@ -58,7 +58,7 @@ const artifactBuildInputFiles = new Set([
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/dev-artifact-publish.mjs",
"scripts/g14-artifact-publish.mjs",
"scripts/src/dev-artifact-services.mjs"
]);
@@ -654,10 +654,10 @@ async function validateLocalContracts(reporter, contracts, optionalReports, targ
function validateArtifactPublishReport(reporter, artifactReport, artifactIdentity, targetShortCommit, targetCommit, targetRef) {
if (!artifactReport) {
reporter.check("dev-artifact-publish-report", "registry", "blocked", "No DEV artifact publish report is present.");
reporter.check("g14-artifact-publish-report", "registry", "blocked", "No G14 artifact publish report is present.");
reporter.block({
type: "runtime_blocker",
scope: "dev-artifact-publish",
scope: "g14-artifact-publish",
summary: `${defaultArtifactReportPath} is missing, so the preflight has no publish evidence for HWLAB runtime artifacts.`,
nextTask: `Run the DEV artifact publish workflow and keep its temporary JSON at ${defaultArtifactReportPath}; do not commit report output.`
});
@@ -701,7 +701,7 @@ function validateArtifactPublishReport(reporter, artifactReport, artifactIdentit
const summary = sourceMatchesTarget
? "DEV artifact publish report covers all required enabled service IDs with immutable digests for the target commit, and records disabled service reasons."
: `DEV artifact publish report covers artifact source ${artifactIdentity.artifactSource.shortCommitId}; target ${targetRef} ${targetShortCommit} has no artifact build input changes since that source.`;
reporter.check("dev-artifact-publish-report", "registry", "pass", summary, [artifactIdentity?.targetCoverage].filter(Boolean));
reporter.check("g14-artifact-publish-report", "registry", "pass", summary, [artifactIdentity?.targetCoverage].filter(Boolean));
return;
}
@@ -711,14 +711,14 @@ function validateArtifactPublishReport(reporter, artifactReport, artifactIdentit
? `source states resolved: ${sourcePresentCount} source-present, ${intentionallyDisabledCount} intentionally-disabled`
: "source states are not fully resolved";
reporter.check(
"dev-artifact-publish-report",
"g14-artifact-publish-report",
"registry",
"blocked",
`DEV artifact publish report status is ${artifactPublish?.status ?? "missing"} with ${publishedCount}/${requiredServices.length || serviceCount} required services published for source ${artifactPublish?.sourceCommitId ?? artifactReport.commitId ?? "unknown"}; ${sourceSummary}.`
);
reporter.block({
type: "runtime_blocker",
scope: "dev-artifact-publish",
scope: "g14-artifact-publish",
summary: `${defaultArtifactReportPath} does not prove all required HWLAB service artifacts for ${targetRef} ${targetShortCommit}; current status is ${artifactPublish?.status ?? "missing"} with ${publishedCount}/${requiredServices.length || serviceCount} required services published and ${sourceSummary}.`,
nextTask: "Complete DEV artifact publishing for every enabled required HWLAB service at the artifact source commit, or prove the target commit has no artifact build input changes; keep disabled services marked not_published with reasons."
});
+1 -1
View File
@@ -230,7 +230,7 @@ export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } =
readRepoText(root, "internal/cloud/m3-io-control.mjs"),
readRepoText(root, "web/hwlab-cloud-web/app.mjs"),
readRepoText(root, "web/hwlab-cloud-web/index.html"),
readRepoText(root, "scripts/dev-artifact-publish.mjs"),
readRepoText(root, "scripts/artifact-publish.mjs"),
readRepoText(root, "internal/dev-entrypoint/cloud-web-routes.mjs")
]);
+8 -6
View File
@@ -236,7 +236,7 @@ function assertDurableRuntimeRunbook(value) {
}
}
function assertCloudApiImageRuntimeEntrypoints({ packageJson, ciJson, artifactPublisher, devCdApplySource }) {
function assertCloudApiImageRuntimeEntrypoints({ packageJson, ciJson, artifactPublisher, gitopsRenderSource }) {
const label = "cloud-api image runtime maintenance entrypoints";
for (const expected of [
"COPY internal ./internal",
@@ -250,14 +250,16 @@ function assertCloudApiImageRuntimeEntrypoints({ packageJson, ciJson, artifactPu
"cmd/hwlab-cloud-api/provision.mjs",
"cmd/hwlab-cloud-api/migrate.mjs"
]) {
assertIncludes(devCdApplySource, expected, `${label} DEV CD job command`);
assertIncludes(packageJson.scripts?.check, `node --check ${expected}`, `${label} package check`);
assertIncludes(packageJson.scripts?.check, `node ${expected} --check`, `${label} package runtime check`);
}
for (const expected of ["scripts/g14-artifact-publish.mjs", "runtime-dev", "runtime-prod"]) {
assertIncludes(gitopsRenderSource, expected, `${label} G14 GitOps render source`);
}
assert.equal(
ciJson.commands?.some((command) => command.run === "npm run check"),
ciJson.commands?.some((command) => command.id === "g14-contract-check"),
true,
`${label} CI must run npm run check`
`${label} CI must run the G14 contract check`
);
}
@@ -441,8 +443,8 @@ assertDurableRuntimeRunbook(await readText("docs/reference/dev-runtime-boundary.
assertCloudApiImageRuntimeEntrypoints({
packageJson: await readJSON("package.json"),
ciJson: await readJSON("CI.json"),
artifactPublisher: await readText("scripts/dev-artifact-publish.mjs"),
devCdApplySource: await readText("scripts/src/dev-cd-apply.mjs")
artifactPublisher: await readText("scripts/artifact-publish.mjs"),
gitopsRenderSource: await readText("scripts/g14-gitops-render.mjs")
});
console.log(
-9
View File
@@ -1,9 +0,0 @@
import { runCicdWorker } from "./cicd-jobs.mjs";
const jobId = process.argv[2];
if (!jobId) {
process.stderr.write(`${JSON.stringify({ ok: false, code: "missing-job-id", message: "usage: node tools/hwlab-cli/lib/cicd-job-worker.mjs <jobId>" }, null, 2)}\n`);
process.exitCode = 1;
} else {
process.exitCode = await runCicdWorker({ repoRoot: process.cwd(), jobId });
}
-398
View File
@@ -1,398 +0,0 @@
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { createWriteStream } from "node:fs";
import { mkdir, open, readdir, readFile, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const stateRoot = ".state/hwlab-cicd/jobs";
const knownKinds = new Set(["ci-publish", "dev-cd-apply"]);
function isoNow(now) {
return (now ? now() : new Date()).toISOString();
}
function compactTimestamp(value) {
return value.replace(/[-:.]/gu, "").slice(0, 15);
}
function makeJobId(kind, now) {
return `${kind}-${compactTimestamp(isoNow(now))}-${randomBytes(3).toString("hex")}`;
}
function jobDir(repoRoot, jobId) {
return path.join(repoRoot, stateRoot, jobId);
}
function jobPaths(repoRoot, jobId) {
const dir = jobDir(repoRoot, jobId);
return {
dir,
specPath: path.join(dir, "spec.json"),
statePath: path.join(dir, "state.json"),
stdoutPath: path.join(dir, "stdout.log"),
stderrPath: path.join(dir, "stderr.log"),
reportPath: path.join(dir, "report.json")
};
}
async function readJson(filePath) {
return JSON.parse(await readFile(filePath, "utf8"));
}
async function writeJsonFile(filePath, value) {
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function result(exitCode, payload, stream = "stdout") {
return { exitCode, payload, stream };
}
function error(code, message, details = {}) {
return { ok: false, code, message, ...details };
}
function followup(jobId) {
return {
status: `hwlab-cli cicd status ${jobId}`,
logs: `hwlab-cli cicd logs ${jobId}`,
report: `hwlab-cli cicd report ${jobId}`
};
}
function optionValue(options, name, fallback = null) {
return options.get(name) ?? fallback;
}
function positiveInteger(value, fallback) {
if (value == null) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function buildJobSpec({ kind, flags, options, repoRoot, env, now }) {
if (!knownKinds.has(kind)) {
return { error: error("unsupported-cicd-kind", "Use --kind ci-publish or --kind dev-cd-apply.", { kind }) };
}
const jobId = makeJobId(kind, now);
const paths = jobPaths(repoRoot, jobId);
const envPatch = {};
const args = [];
if (kind === "ci-publish") {
args.push("scripts/dev-artifact-publish.mjs", "--publish", "--report", paths.reportPath, "--quiet-build");
const services = optionValue(options, "--services");
if (services) args.push("--services", services);
const concurrency = positiveInteger(optionValue(options, "--concurrency"), 4);
args.push("--concurrency", String(concurrency));
envPatch.HWLAB_CI_ARTIFACT_RUN_ID = optionValue(options, "--owner-task-id", jobId);
envPatch.HWLAB_CI_ARTIFACT_RUN_OWNER = optionValue(options, "--owner", "hwlab-cli");
} else {
if (!flags.has("--confirm-dev") || !flags.has("--confirmed-non-production")) {
return {
error: error("confirmation-required", "DEV CD apply requires --confirm-dev --confirmed-non-production.", {
requiredFlags: ["--confirm-dev", "--confirmed-non-production"]
})
};
}
args.push(
"scripts/dev-cd-apply.mjs",
"--apply",
"--confirm-dev",
"--confirmed-non-production",
"--owner-task-id",
optionValue(options, "--owner-task-id", jobId),
"--report",
paths.reportPath
);
if (flags.has("--break-stale-lock")) args.push("--break-stale-lock");
const concurrency = optionValue(options, "--concurrency");
if (concurrency) envPatch.HWLAB_DEV_CD_CONCURRENCY = String(positiveInteger(concurrency, 4));
const registryManifestBaseUrl = optionValue(options, "--registry-manifest-base-url");
if (registryManifestBaseUrl) args.push("--registry-manifest-base-url", registryManifestBaseUrl);
}
if (optionValue(options, "--target-ref")) args.push("--target-ref", optionValue(options, "--target-ref"));
if (optionValue(options, "--kubeconfig")) args.push("--kubeconfig", optionValue(options, "--kubeconfig"));
return {
spec: {
jobId,
kind,
cwd: repoRoot,
node: process.execPath,
command: process.execPath,
args,
envPatch,
createdAt: isoNow(now),
host: os.hostname(),
paths
},
paths,
env
};
}
async function defaultSpawnDetached({ repoRoot, jobId, env }) {
const workerPath = path.join(repoRoot, "tools/hwlab-cli/lib/cicd-job-worker.mjs");
const child = spawn(process.execPath, [workerPath, jobId], {
cwd: repoRoot,
env,
detached: true,
stdio: "ignore"
});
child.unref();
return { pid: child.pid };
}
async function submitJob(ctx) {
const kind = optionValue(ctx.options, "--kind");
const built = buildJobSpec({ ...ctx, kind });
if (built.error) return result(1, built.error, "stderr");
await mkdir(built.paths.dir, { recursive: true });
await writeJsonFile(built.paths.specPath, built.spec);
const initialState = {
ok: true,
jobId: built.spec.jobId,
kind: built.spec.kind,
status: "submitted",
nonBlocking: true,
createdAt: built.spec.createdAt,
updatedAt: built.spec.createdAt,
cwd: built.spec.cwd,
command: [path.basename(built.spec.command), ...built.spec.args].join(" "),
statePath: built.paths.statePath,
stdoutPath: built.paths.stdoutPath,
stderrPath: built.paths.stderrPath,
reportPath: built.paths.reportPath
};
await writeJsonFile(built.paths.statePath, initialState);
const launcher = ctx.spawnDetached ?? defaultSpawnDetached;
const launched = await launcher({ repoRoot: ctx.repoRoot, jobId: built.spec.jobId, env: ctx.env, spec: built.spec });
const submitted = {
...initialState,
pid: launched?.pid ?? null,
followup: followup(built.spec.jobId)
};
await writeJsonFile(built.paths.statePath, submitted);
return result(0, submitted);
}
async function appendLog(filePath, line) {
await writeFile(filePath, `${line}\n`, { flag: "a" });
}
function reportSummary(report) {
if (report?.artifactPublish) {
return {
kind: "ci-publish",
status: report.artifactPublish.status ?? report.status ?? null,
sourceCommitId: report.sourceCommitId ?? report.commitId ?? null,
serviceCount: report.artifactPublish.serviceCount ?? null,
requiredServiceCount: report.artifactPublish.requiredServiceCount ?? null,
publishedCount: report.artifactPublish.publishedCount ?? null,
timings: report.artifactPublish.timings ?? null,
blockers: report.blockers ?? []
};
}
if (report?.devCdApply) {
return {
kind: "dev-cd-apply",
status: report.status ?? null,
commitId: report.commitId ?? null,
target: report.target ?? null,
transaction: report.transaction
? {
transactionId: report.transaction.transactionId ?? null,
ownerTaskId: report.transaction.ownerTaskId ?? null,
phases: report.transaction.phases ?? [],
release: report.transaction.release ?? null
}
: null,
steps: report.devCdApply.steps?.map((step) => ({ id: step.id, status: step.status, reportPath: step.reportPath })) ?? [],
blockers: report.blockers ?? []
};
}
return {
kind: "generic",
status: report?.status ?? report?.ok ?? null,
keys: report && typeof report === "object" ? Object.keys(report).slice(0, 20) : []
};
}
async function runJob(ctx) {
const jobId = ctx.rest[0];
if (!jobId) return result(1, error("missing-job-id", "usage: hwlab-cli cicd run-job <jobId>"), "stderr");
const paths = jobPaths(ctx.repoRoot, jobId);
const spec = await readJson(paths.specPath);
const startedAt = isoNow(ctx.now);
await writeJsonFile(paths.statePath, {
ok: true,
jobId,
kind: spec.kind,
status: "running",
nonBlocking: true,
createdAt: spec.createdAt,
startedAt,
updatedAt: startedAt,
cwd: spec.cwd,
pid: process.pid,
command: [path.basename(spec.command), ...spec.args].join(" "),
statePath: paths.statePath,
stdoutPath: paths.stdoutPath,
stderrPath: paths.stderrPath,
reportPath: paths.reportPath
});
await appendLog(paths.stdoutPath, `[hwlab-cli] job ${jobId} started at ${startedAt}`);
const child = spawn(spec.command, spec.args, {
cwd: spec.cwd,
env: { ...process.env, ...spec.envPatch },
stdio: ["ignore", "pipe", "pipe"]
});
const stdoutStream = createWriteStream(paths.stdoutPath, { flags: "a" });
const stderrStream = createWriteStream(paths.stderrPath, { flags: "a" });
child.stdout.pipe(stdoutStream);
child.stderr.pipe(stderrStream);
const exitCode = await new Promise((resolve) => {
child.on("close", resolve);
});
stdoutStream.end();
stderrStream.end();
const finishedAt = isoNow(ctx.now);
const state = await readJson(paths.statePath);
let summary = null;
try {
summary = reportSummary(await readJson(paths.reportPath));
} catch {
summary = null;
}
const finalState = {
...state,
status: exitCode === 0 ? "succeeded" : "failed",
ok: exitCode === 0,
exitCode,
updatedAt: finishedAt,
finishedAt,
durationMs: Date.parse(finishedAt) - Date.parse(startedAt),
reportSummary: summary,
followup: followup(jobId)
};
await writeJsonFile(paths.statePath, finalState);
return result(exitCode === 0 ? 0 : 2, finalState, exitCode === 0 ? "stdout" : "stderr");
}
async function readState(repoRoot, jobId) {
const paths = jobPaths(repoRoot, jobId);
const state = await readJson(paths.statePath);
return { paths, state };
}
async function statusJob(ctx) {
const jobId = ctx.rest[0];
if (!jobId) return result(1, error("missing-job-id", "usage: hwlab-cli cicd status <jobId>"), "stderr");
try {
const { state } = await readState(ctx.repoRoot, jobId);
return result(0, { ...state, followup: followup(jobId) });
} catch (err) {
return result(2, error("job-not-found", `No CI/CD job state found for ${jobId}.`, { detail: err.message }), "stderr");
}
}
async function tailFile(filePath, bytes) {
try {
const info = await stat(filePath);
const length = Math.min(info.size, bytes);
const handle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(length);
await handle.read(buffer, 0, length, info.size - length);
return { path: filePath, totalBytes: info.size, returnedBytes: length, truncated: info.size > length, text: buffer.toString("utf8") };
} finally {
await handle.close();
}
} catch {
return { path: filePath, totalBytes: 0, returnedBytes: 0, truncated: false, text: "" };
}
}
async function logsJob(ctx) {
const jobId = ctx.rest[0];
if (!jobId) return result(1, error("missing-job-id", "usage: hwlab-cli cicd logs <jobId>"), "stderr");
const bytes = Math.min(positiveInteger(optionValue(ctx.options, "--tail-bytes"), 8000), 20000);
try {
const { paths, state } = await readState(ctx.repoRoot, jobId);
return result(0, {
ok: true,
jobId,
status: state.status,
tailBytes: bytes,
stdout: await tailFile(paths.stdoutPath, bytes),
stderr: await tailFile(paths.stderrPath, bytes),
followup: followup(jobId)
});
} catch (err) {
return result(2, error("job-not-found", `No CI/CD job state found for ${jobId}.`, { detail: err.message }), "stderr");
}
}
async function reportJob(ctx) {
const jobId = ctx.rest[0];
if (!jobId) return result(1, error("missing-job-id", "usage: hwlab-cli cicd report <jobId>"), "stderr");
try {
const { paths, state } = await readState(ctx.repoRoot, jobId);
const report = await readJson(paths.reportPath);
return result(0, {
ok: true,
jobId,
status: state.status,
reportPath: paths.reportPath,
summary: reportSummary(report),
report: ctx.flags.has("--full") ? report : undefined,
followup: followup(jobId)
});
} catch (err) {
return result(2, error("report-not-ready", `No report is available for ${jobId}.`, { detail: err.message }), "stderr");
}
}
async function listJobs(ctx) {
const root = path.join(ctx.repoRoot, stateRoot);
const limit = Math.min(positiveInteger(optionValue(ctx.options, "--limit"), 20), 100);
try {
const names = (await readdir(root)).sort().reverse().slice(0, limit);
const jobs = [];
for (const name of names) {
try {
jobs.push((await readState(ctx.repoRoot, name)).state);
} catch {
// Ignore incomplete job directories; logs/report still remain on disk.
}
}
return result(0, { ok: true, command: "hwlab-cli cicd jobs", count: jobs.length, jobs });
} catch {
return result(0, { ok: true, command: "hwlab-cli cicd jobs", count: 0, jobs: [] });
}
}
export async function runCicdCommand(ctx) {
if (ctx.subcommand === "submit") return submitJob(ctx);
if (ctx.subcommand === "status") return statusJob(ctx);
if (ctx.subcommand === "logs") return logsJob(ctx);
if (ctx.subcommand === "report") return reportJob(ctx);
if (ctx.subcommand === "jobs") return listJobs(ctx);
return result(1, error("usage", "usage: hwlab-cli cicd submit --kind <ci-publish|dev-cd-apply> | status|logs|report <jobId> | jobs"), "stderr");
}
export async function runCicdWorker({ repoRoot = process.cwd(), jobId, now }) {
const outcome = await runJob({
repoRoot,
rest: [jobId],
now
});
return outcome.exitCode;
}
+10 -19
View File
@@ -1,6 +1,5 @@
import { DEV_ENDPOINT, loadMvpGateSummary } from "../../../internal/mvp-gate/summary.mjs";
import { runM3IoSkillCommand } from "../../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
import { runCicdCommand } from "./cicd-jobs.mjs";
function formatJson(value) {
return JSON.stringify(value, null, 2);
@@ -174,12 +173,7 @@ function writeHelp(stdout) {
"m3 status --api-base-url URL",
"m3 io --action do.write --value true --approved --api-base-url URL",
"m3 io --action di.read --api-base-url URL",
"cicd submit --kind ci-publish --concurrency 4",
"cicd submit --kind dev-cd-apply --confirm-dev --confirmed-non-production --concurrency 4",
"cicd status JOB_ID",
"cicd logs JOB_ID --tail-bytes 8000",
"cicd report JOB_ID",
"cicd jobs --limit 20",
"G14 CI/CD is managed by native Tekton + GitOps; hwlab-cli cicd is removed",
"test e2e --env dev --mvp",
"test e2e --env dev --mvp --dry-run",
"test e2e --env dev --mvp --live --confirm-dev --confirmed-non-production"
@@ -202,18 +196,15 @@ export async function runCli(argv, io) {
const { flags, options } = parseArgs(rest);
if (command === "cicd") {
const outcome = await runCicdCommand({
subcommand,
rest,
flags,
options,
repoRoot,
env,
now: io.now,
spawnDetached: io.spawnDetached
});
writeJson(outcome.stream === "stderr" ? stderr : stdout, outcome.payload);
return outcome.exitCode;
writeJson(stderr, makeError("legacy-cicd-removed", "hwlab-cli cicd was removed from the active G14 release path. Use G14 k3s Tekton/GitOps via tran G14:k3s and scripts/g14-gitops-render.mjs.", {
subcommand: subcommand ?? null,
replacement: {
observePipelineRuns: "tran G14:k3s kubectl get pipelineruns -n hwlab-ci",
observeArgo: "tran G14:k3s kubectl -n argocd get application hwlab-g14-dev hwlab-g14-prod",
renderSource: "node scripts/g14-gitops-render.mjs --check"
}
}));
return 2;
}
if (command === "m3" && ["io", "status"].includes(subcommand)) {
+8 -77
View File
@@ -1,7 +1,4 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
@@ -34,80 +31,14 @@ async function captureCli(args, options = {}) {
return { exitCode, stdout, stderr };
}
test("repo hwlab-cli cicd submit is non-blocking and records follow-up commands", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-cicd-"));
const launched = [];
const result = await captureCli(
["cicd", "submit", "--kind", "ci-publish", "--services", "hwlab-cloud-web", "--concurrency", "2"],
{
cwd,
spawnDetached: async (job) => {
launched.push(job);
return { pid: 123456 };
}
}
);
assert.equal(result.exitCode, 0, result.stderr);
const body = JSON.parse(result.stdout);
assert.equal(body.ok, true);
assert.equal(body.nonBlocking, true);
assert.equal(body.kind, "ci-publish");
assert.match(body.jobId, /^ci-publish-/u);
assert.equal(body.pid, 123456);
assert.equal(body.followup.status, `hwlab-cli cicd status ${body.jobId}`);
assert.equal(launched.length, 1);
const spec = JSON.parse(await readFile(path.join(cwd, ".state", "hwlab-cicd", "jobs", body.jobId, "spec.json"), "utf8"));
assert.deepEqual(spec.args.slice(0, 4), ["scripts/dev-artifact-publish.mjs", "--publish", "--report", body.reportPath]);
assert.ok(spec.args.includes("--quiet-build"));
assert.equal(spec.envPatch.HWLAB_CI_ARTIFACT_RUN_ID, body.jobId);
});
test("repo hwlab-cli cicd status/logs/report disclose bounded job state", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-cicd-"));
const submit = await captureCli(["cicd", "submit", "--kind", "ci-publish"], {
cwd,
spawnDetached: async () => ({ pid: 123457 })
});
const submitted = JSON.parse(submit.stdout);
const jobDir = path.join(cwd, ".state", "hwlab-cicd", "jobs", submitted.jobId);
await writeFile(path.join(jobDir, "stdout.log"), "build line 1\nbuild line 2\n", "utf8");
await writeFile(path.join(jobDir, "stderr.log"), "warning line\n", "utf8");
await writeFile(
path.join(jobDir, "report.json"),
`${JSON.stringify({
sourceCommitId: "abc123",
artifactPublish: {
status: "published",
serviceCount: 1,
requiredServiceCount: 1,
publishedCount: 1,
timings: { durationMs: 42 }
},
blockers: []
})}\n`,
"utf8"
);
const status = await captureCli(["cicd", "status", submitted.jobId], { cwd });
assert.equal(status.exitCode, 0, status.stderr);
assert.equal(JSON.parse(status.stdout).status, "submitted");
const logs = await captureCli(["cicd", "logs", submitted.jobId, "--tail-bytes", "12"], { cwd });
assert.equal(logs.exitCode, 0, logs.stderr);
const logBody = JSON.parse(logs.stdout);
assert.equal(logBody.stdout.returnedBytes, 12);
assert.equal(logBody.stdout.truncated, true);
assert.ok(logBody.stdout.text.includes("line 2"));
const report = await captureCli(["cicd", "report", submitted.jobId], { cwd });
assert.equal(report.exitCode, 0, report.stderr);
const reportBody = JSON.parse(report.stdout);
assert.equal(reportBody.summary.kind, "ci-publish");
assert.equal(reportBody.summary.status, "published");
assert.equal(reportBody.summary.publishedCount, 1);
assert.equal(Object.hasOwn(reportBody, "report"), false);
test("repo hwlab-cli cicd is removed from the active G14 release path", async () => {
const result = await captureCli(["cicd", "submit", "--kind", "ci-publish"]);
assert.equal(result.exitCode, 2);
const body = JSON.parse(result.stderr);
assert.equal(body.ok, false);
assert.equal(body.code, "legacy-cicd-removed");
assert.match(body.message, /G14 release path/u);
assert.match(body.replacement.observePipelineRuns, /tran G14:k3s/u);
});
test("repo hwlab-cli m3 status uses Skill CLI and calls only HWLAB API /v1/m3/status", async () => {
+1 -1
View File
@@ -7,7 +7,7 @@
"hwlab-cli": "./bin/hwlab-cli.mjs"
},
"scripts": {
"check": "node --check bin/hwlab-cli.mjs && node --check lib/cli.mjs && node --check lib/cicd-jobs.mjs && node --check lib/cicd-job-worker.mjs && node --test lib/cli.test.mjs && node ../../scripts/l6-cli-web-smoke.mjs",
"check": "node --check bin/hwlab-cli.mjs && node --check lib/cli.mjs && node --test lib/cli.test.mjs && node ../../scripts/l6-cli-web-smoke.mjs",
"health": "node bin/hwlab-cli.mjs health"
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ const helpMarkdown = fs.readFileSync(path.resolve(rootDir, "help.md"), "utf8");
const buildScript = fs.readFileSync(path.resolve(rootDir, "scripts/build.mjs"), "utf8");
const distContractScript = fs.readFileSync(path.resolve(rootDir, "scripts/dist-contract.mjs"), "utf8");
const markedLicense = fs.readFileSync(path.resolve(rootDir, "third_party/marked/LICENSE"), "utf8");
const artifactPublisher = fs.readFileSync(path.resolve(repoRoot, "scripts/dev-artifact-publish.mjs"), "utf8");
const artifactPublisher = fs.readFileSync(path.resolve(repoRoot, "scripts/artifact-publish.mjs"), "utf8");
const cloudWebRoutes = fs.readFileSync(path.resolve(repoRoot, "internal/dev-entrypoint/cloud-web-routes.mjs"), "utf8");
const cloudWebProxy = fs.readFileSync(path.resolve(repoRoot, "internal/dev-entrypoint/cloud-web-proxy.mjs"), "utf8");
const cloudApiServer = fs.readFileSync(path.resolve(repoRoot, "internal/cloud/server.mjs"), "utf8");
@@ -28,7 +28,7 @@ export function runM3ControlPanelGuard() {
const html = readText("web/hwlab-cloud-web/index.html");
const app = readText("web/hwlab-cloud-web/app.mjs");
const styles = readText("web/hwlab-cloud-web/styles.css");
const artifactPublisher = readText("scripts/dev-artifact-publish.mjs");
const artifactPublisher = readText("scripts/artifact-publish.mjs");
const cloudWebRouteSource = readText("internal/dev-entrypoint/cloud-web-routes.mjs");
const browserSource = `${html}\n${app}\n${styles}`;