feat: add dev base image preflight
This commit is contained in:
@@ -29,6 +29,8 @@ skeleton files agree with each other and do not drift into PROD.
|
||||
| `deploy/deploy.json` | DEV deploy manifest skeleton that the catalog explains. |
|
||||
| `CI.json` | Lightweight command and forbidden-action skeleton. |
|
||||
| `scripts/validate-artifact-catalog.mjs` | Local static validator for the catalog, deploy manifest, and CI forbidden list. |
|
||||
| `scripts/preflight-dev-base-image.mjs` | Local DEV builder base-image preflight for future artifact publish. |
|
||||
| `docs/dev-base-image-preflight.md` | Operator contract for interpreting preflight readiness and blockers. |
|
||||
|
||||
## Pin Rules
|
||||
|
||||
@@ -64,12 +66,30 @@ publish result that includes registry digests and provenance. That publish
|
||||
result may be compared with this contract, but it must not be inferred from this
|
||||
contract.
|
||||
|
||||
## DEV Builder Preflight
|
||||
|
||||
Before a future DEV artifact publish attempts to build or publish service
|
||||
images, run:
|
||||
|
||||
```sh
|
||||
npm run --silent dev-base-image:preflight
|
||||
```
|
||||
|
||||
The preflight is local-only and checks for an approved builder base image through
|
||||
`HWLAB_DEV_BASE_IMAGE`, a local `node:20-*` Docker image, or an explicit local
|
||||
HWLAB dev base tag such as `hwlab-dev-base:<tag>`. It prints the image source,
|
||||
local tag, image ID, publish usability, blockers, and next steps. A blocked
|
||||
result means artifact publish must stop and must not be reported as a successful
|
||||
publish.
|
||||
|
||||
## Validation
|
||||
|
||||
Run the local static validator:
|
||||
|
||||
```sh
|
||||
node --check scripts/validate-artifact-catalog.mjs
|
||||
node --check scripts/preflight-dev-base-image.mjs
|
||||
node --check scripts/src/dev-base-image-preflight.mjs
|
||||
node scripts/validate-artifact-catalog.mjs
|
||||
```
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ secrets, enable PROD, or push to GHCR/Docker Hub/other third-party registries.
|
||||
- Environment: `dev`.
|
||||
- Namespace: `hwlab-dev`.
|
||||
- Default registry prefix: `127.0.0.1:5000/hwlab`.
|
||||
- Default base image: `node:22-bookworm-slim`, which must already exist in the
|
||||
Docker daemon because the build runs with `--pull=false`.
|
||||
- Base image: resolved by `scripts/preflight-dev-base-image.mjs` from
|
||||
`HWLAB_DEV_BASE_IMAGE` or a local `node:20-*` Docker image. There is no
|
||||
network pull or third-party fallback; the build runs with `--pull=false`.
|
||||
- Image tag: first seven characters of the Git commit used as build source.
|
||||
- Required image labels:
|
||||
- `hwlab.pikastech.local/repo`
|
||||
@@ -23,9 +24,17 @@ secrets, enable PROD, or push to GHCR/Docker Hub/other third-party registries.
|
||||
Static check:
|
||||
|
||||
```sh
|
||||
node --check scripts/preflight-dev-base-image.mjs
|
||||
node --check scripts/src/dev-base-image-preflight.mjs
|
||||
node --check scripts/dev-artifact-publish.mjs
|
||||
```
|
||||
|
||||
Base image preflight:
|
||||
|
||||
```sh
|
||||
node scripts/preflight-dev-base-image.mjs
|
||||
```
|
||||
|
||||
Preflight and report generation:
|
||||
|
||||
```sh
|
||||
@@ -46,7 +55,8 @@ node scripts/dev-artifact-publish.mjs --publish
|
||||
|
||||
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.
|
||||
that names PROD. Use `--base-image` only to override `HWLAB_DEV_BASE_IMAGE` with
|
||||
an approved local image for that run.
|
||||
|
||||
## Report
|
||||
|
||||
@@ -69,6 +79,11 @@ Each service record contains:
|
||||
push output contains a digest. The script records blockers instead of claiming a
|
||||
publish when build, push, base image, registry, contract, or safety checks fail.
|
||||
|
||||
The report also includes `artifactPublish.baseImagePreflight` with the image
|
||||
source, local tag, local image ID, `publishUsable` gate, blockers, and next
|
||||
steps. When that preflight is blocked, artifact publish is blocked and no build
|
||||
or push is attempted.
|
||||
|
||||
## Known Implementation States
|
||||
|
||||
- `repo-entrypoint`: the repository has `cmd/<serviceId>/main.mjs`.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# HWLAB DEV Base Image Preflight
|
||||
|
||||
This preflight is the lightweight builder gate for DEV artifact publish work. It
|
||||
checks whether the D601 builder has an approved local base image before any
|
||||
artifact publish attempt claims readiness.
|
||||
|
||||
The command is local-only. It does not pull, build, push, deploy, contact DEV or
|
||||
PROD, read secrets, or use UniDesk runtime services as substitutes.
|
||||
|
||||
## Current Repository Constraints
|
||||
|
||||
- No repository Dockerfile is present in the DEV skeleton.
|
||||
- `deploy/deploy.json` and `deploy/artifact-catalog.dev.json` pin service image
|
||||
names to `ghcr.io/pikastech/<hwlab-service-id>:<commit>`.
|
||||
- The artifact catalog is `contract-skeleton` only. Service entries must keep
|
||||
`publishState: "skeleton-only"` and `digest: "not_published"` until real
|
||||
publish evidence exists.
|
||||
- The catalog and runtime boundary forbid PROD deployment, secret reads, force
|
||||
push, real publish claims, and UniDesk runtime substitution.
|
||||
- A builder base image is separate from HWLAB service artifact images. This
|
||||
preflight only decides whether #35 may proceed to build/publish; it does not
|
||||
prove that any service image was published.
|
||||
|
||||
## Command
|
||||
|
||||
```sh
|
||||
npm run --silent dev-base-image:preflight
|
||||
```
|
||||
|
||||
Equivalent direct command:
|
||||
|
||||
```sh
|
||||
node scripts/preflight-dev-base-image.mjs
|
||||
```
|
||||
|
||||
The direct command always prints JSON. Use `npm run --silent` when npm is the
|
||||
entry point and the output needs to be machine parsed. Exit code `0` means
|
||||
`status: "ready"` and the base image can be used by #35 DEV artifact publish.
|
||||
Exit code `2` means `status: "blocked"` and artifact publish must stop.
|
||||
|
||||
## Image Selection
|
||||
|
||||
Selection order is intentionally narrow:
|
||||
|
||||
1. If `HWLAB_DEV_BASE_IMAGE` is set, it must name an approved image that already
|
||||
exists in the local Docker image cache.
|
||||
2. If `HWLAB_DEV_BASE_IMAGE` is not set, the preflight looks for local
|
||||
`node:20-*` images and selects the first deterministic local tag.
|
||||
3. If neither source is available, the result is a blocker.
|
||||
|
||||
Explicit base image values must match local `node:20-*`, `hwlab-dev-base:*`, or
|
||||
`hwlab-node20-base:*` references. They must not reference UniDesk, Code Queue,
|
||||
`backend-core`, `provider-gateway`, `microservice-proxy`, or an HWLAB runtime
|
||||
service image. A service image such as
|
||||
`ghcr.io/pikastech/hwlab-cloud-api:<sha>` is an artifact output, not a builder
|
||||
base image.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Important fields:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `status` | `ready` or `blocked`. |
|
||||
| `imageSource` | `env:HWLAB_DEV_BASE_IMAGE`, `local-docker-node:20-*`, or `none`. |
|
||||
| `requestedImage` | The explicit environment value, when present. |
|
||||
| `localTag` | Local Docker tag selected for the builder base image. |
|
||||
| `imageId` | Local Docker image ID from `docker image inspect`. |
|
||||
| `publishUsable` | Boolean gate for #35 artifact publish. |
|
||||
| `blockers` | Deterministic reasons artifact publish must stop. |
|
||||
| `nextSteps` | Operational fixes that do not require secret reads or runtime substitution. |
|
||||
|
||||
## Blocker Handling
|
||||
|
||||
When blocked, preload or build an approved DEV builder base image into the D601
|
||||
Docker cache, set `HWLAB_DEV_BASE_IMAGE` to the exact local tag if it is not a
|
||||
`node:20-*` tag, and rerun the preflight. Do not claim artifact publish success
|
||||
until the preflight returns `status: "ready"`.
|
||||
|
||||
The preflight may use UniDesk and Code Queue as scheduling infrastructure only.
|
||||
It must not use their runtime images as HWLAB service images or builder base
|
||||
image substitutes.
|
||||
+2
-1
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"validate": "node scripts/validate-contract.mjs",
|
||||
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node scripts/validate-contract.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
|
||||
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node scripts/validate-contract.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
|
||||
"dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
|
||||
"m1:smoke": "node scripts/m1-contract-smoke.mjs",
|
||||
"dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty",
|
||||
"dev:evidence:check": "node scripts/dev-evidence-blocker-aggregator.mjs --check",
|
||||
|
||||
+121
-101
@@ -4,22 +4,26 @@
|
||||
"reportVersion": "v1",
|
||||
"issue": "pikasTech/HWLAB#31",
|
||||
"taskId": "dev-artifact-publish",
|
||||
"commitId": "1e8d009",
|
||||
"commitId": "ad903a7",
|
||||
"acceptanceLevel": "dev_artifact_publish",
|
||||
"devOnly": true,
|
||||
"prodDisabled": true,
|
||||
"sourceContract": {
|
||||
"status": "pass",
|
||||
"status": "blocked",
|
||||
"documents": [
|
||||
"docs/dev-acceptance-matrix.md",
|
||||
"docs/m0-contract-audit.md",
|
||||
"docs/artifact-catalog.md",
|
||||
"docs/dev-artifact-publish.md"
|
||||
"docs/dev-artifact-publish.md",
|
||||
"docs/dev-base-image-preflight.md"
|
||||
],
|
||||
"summary": "DEV artifact publish evidence for pikasTech/HWLAB#35, stored in the DEV gate report envelope."
|
||||
},
|
||||
"validationCommands": [
|
||||
"node --check scripts/dev-artifact-publish.mjs",
|
||||
"node --check scripts/preflight-dev-base-image.mjs",
|
||||
"node --check scripts/src/dev-base-image-preflight.mjs",
|
||||
"node scripts/preflight-dev-base-image.mjs",
|
||||
"node scripts/dev-artifact-publish.mjs --preflight --no-report",
|
||||
"node --check scripts/validate-dev-gate-report.mjs",
|
||||
"node scripts/validate-dev-gate-report.mjs"
|
||||
@@ -45,16 +49,23 @@
|
||||
"summary": "DEV deploy dry-run is not implied by artifact publication."
|
||||
},
|
||||
"devPreconditions": {
|
||||
"status": "pass",
|
||||
"status": "blocked",
|
||||
"requirements": [
|
||||
"Registry prefix is localhost or private/internal only.",
|
||||
"DEV builder base image preflight returns ready before build or publish.",
|
||||
"Environment remains dev and namespace remains hwlab-dev.",
|
||||
"PROD profile remains disabled.",
|
||||
"No secret or token material is required by this workflow."
|
||||
],
|
||||
"summary": "Fatal DEV-only artifact preconditions passed."
|
||||
"summary": "Fatal preconditions blocked a full DEV artifact publish."
|
||||
},
|
||||
"blockers": [
|
||||
{
|
||||
"type": "environment_blocker",
|
||||
"scope": "base-image",
|
||||
"status": "open",
|
||||
"summary": "HWLAB_DEV_BASE_IMAGE is not set and no local node:20-* image was found in the Docker image cache."
|
||||
},
|
||||
{
|
||||
"type": "runtime_blocker",
|
||||
"scope": "hwlab-router",
|
||||
@@ -72,129 +83,139 @@
|
||||
"scope": "hwlab-edge-proxy",
|
||||
"status": "open",
|
||||
"summary": "hwlab-edge-proxy has no cmd/hwlab-edge-proxy/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation."
|
||||
},
|
||||
{
|
||||
"type": "runtime_blocker",
|
||||
"scope": "hwlab-cli",
|
||||
"status": "open",
|
||||
"summary": "tools/hwlab-cli declares bin/hwlab-cli.mjs but the bin file is absent; a health-only placeholder image was not built as a real CLI artifact."
|
||||
}
|
||||
],
|
||||
"artifactPublish": {
|
||||
"issue": "pikasTech/HWLAB#35",
|
||||
"status": "blocked",
|
||||
"mode": "publish",
|
||||
"mode": "preflight",
|
||||
"repo": "pikasTech/HWLAB",
|
||||
"sourceCommitId": "1e8d009e9531d71cef0762998663b4e3731032c6",
|
||||
"sourceCommitId": "ad903a76e93bc8fab5c95138df15bfcb852bc339",
|
||||
"registryPrefix": "127.0.0.1:5000/hwlab",
|
||||
"baseImage": "node:22-bookworm-slim",
|
||||
"baseImage": null,
|
||||
"baseImagePreflight": {
|
||||
"status": "blocked",
|
||||
"imageSource": "none",
|
||||
"requestedImage": null,
|
||||
"localTag": null,
|
||||
"imageId": null,
|
||||
"repoDigests": [],
|
||||
"publishUsable": false,
|
||||
"blockers": [
|
||||
"HWLAB_DEV_BASE_IMAGE is not set and no local node:20-* image was found in the Docker image cache."
|
||||
],
|
||||
"nextSteps": [
|
||||
"Set HWLAB_DEV_BASE_IMAGE to an approved local DEV builder base image, or preload a local node:20-* image such as node:20-bookworm-slim.",
|
||||
"Rerun this preflight and require status=ready before #35 artifact publish.",
|
||||
"Do not use UniDesk runtime, Code Queue runner, backend-core, provider-gateway, or microservice-proxy images as substitutes."
|
||||
],
|
||||
"containerEngine": {
|
||||
"command": "docker",
|
||||
"available": true,
|
||||
"version": "Docker version 26.1.5+dfsg1, build a72d7cd",
|
||||
"errorCode": null,
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
"environment": "dev",
|
||||
"serviceCount": 13,
|
||||
"builtCount": 9,
|
||||
"publishedCount": 9,
|
||||
"generatedAt": "2026-05-21T16:39:23.804Z",
|
||||
"builtCount": 0,
|
||||
"publishedCount": 0,
|
||||
"generatedAt": "2026-05-21T16:53:05.962Z",
|
||||
"services": [
|
||||
{
|
||||
"serviceId": "hwlab-cloud-api",
|
||||
"status": "published",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:1e8d009",
|
||||
"imageTag": "1e8d009",
|
||||
"digest": "sha256:daa3e09996209242ae409f21802757cfab20fe80adfdcc729dc7144a0758a067",
|
||||
"repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-cloud-api@sha256:daa3e09996209242ae409f21802757cfab20fe80adfdcc729dc7144a0758a067",
|
||||
"status": "preflight_only",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:ad903a7",
|
||||
"imageTag": "ad903a7",
|
||||
"digest": "not_published",
|
||||
"repositoryDigest": null,
|
||||
"runtimeKind": "node-command",
|
||||
"implementationState": "repo-entrypoint",
|
||||
"entrypoint": "cmd/hwlab-cloud-api/main.mjs",
|
||||
"localImageId": "sha256:cfa8de38b9113365764a2a7dffb8732e7049d77ba83329eb5f7f29167dfa13e9"
|
||||
"entrypoint": "cmd/hwlab-cloud-api/main.mjs"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-cloud-web",
|
||||
"status": "published",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:1e8d009",
|
||||
"imageTag": "1e8d009",
|
||||
"digest": "sha256:267de8af9ef4ff2bc60e197ca37f266ab49e75d49cce91909dda64715d47c0c8",
|
||||
"repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-cloud-web@sha256:267de8af9ef4ff2bc60e197ca37f266ab49e75d49cce91909dda64715d47c0c8",
|
||||
"status": "preflight_only",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:ad903a7",
|
||||
"imageTag": "ad903a7",
|
||||
"digest": "not_published",
|
||||
"repositoryDigest": null,
|
||||
"runtimeKind": "cloud-web",
|
||||
"implementationState": "static-web-wrapper",
|
||||
"entrypoint": "web/hwlab-cloud-web/index.html",
|
||||
"localImageId": "sha256:d4bd2ef29fda976ad162092cd95197e130a7ade811aa11784b8396c0ef979884"
|
||||
"entrypoint": "web/hwlab-cloud-web/index.html"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-agent-mgr",
|
||||
"status": "published",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:1e8d009",
|
||||
"imageTag": "1e8d009",
|
||||
"digest": "sha256:12510042c5ab96419b4eafa52314ad58e17e1d1c5a0fbebe4cb6cd50e8935a5a",
|
||||
"repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-agent-mgr@sha256:12510042c5ab96419b4eafa52314ad58e17e1d1c5a0fbebe4cb6cd50e8935a5a",
|
||||
"status": "preflight_only",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:ad903a7",
|
||||
"imageTag": "ad903a7",
|
||||
"digest": "not_published",
|
||||
"repositoryDigest": null,
|
||||
"runtimeKind": "node-command",
|
||||
"implementationState": "repo-entrypoint",
|
||||
"entrypoint": "cmd/hwlab-agent-mgr/main.mjs",
|
||||
"localImageId": "sha256:58ac216ee9faee27e1bcb569d5ce36166d8344aab24c7e4ab69781ef4356e70d"
|
||||
"entrypoint": "cmd/hwlab-agent-mgr/main.mjs"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-agent-worker",
|
||||
"status": "published",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:1e8d009",
|
||||
"imageTag": "1e8d009",
|
||||
"digest": "sha256:1cbfd4840ca567539f5eab5df2e301b4c1e263555b1a1f160900996bc6895c07",
|
||||
"repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-agent-worker@sha256:1cbfd4840ca567539f5eab5df2e301b4c1e263555b1a1f160900996bc6895c07",
|
||||
"status": "preflight_only",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:ad903a7",
|
||||
"imageTag": "ad903a7",
|
||||
"digest": "not_published",
|
||||
"repositoryDigest": null,
|
||||
"runtimeKind": "node-command",
|
||||
"implementationState": "repo-entrypoint",
|
||||
"entrypoint": "cmd/hwlab-agent-worker/main.mjs",
|
||||
"localImageId": "sha256:44fad5f75ce63c82e052827c4536be1c70b08a48d8962da78094a29a6822070e"
|
||||
"entrypoint": "cmd/hwlab-agent-worker/main.mjs"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-gateway",
|
||||
"status": "published",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-gateway:1e8d009",
|
||||
"imageTag": "1e8d009",
|
||||
"digest": "sha256:b6e6213e03773539ab21691bdee973338e270cf639b1deccb8c6d904278a43d8",
|
||||
"repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-gateway@sha256:b6e6213e03773539ab21691bdee973338e270cf639b1deccb8c6d904278a43d8",
|
||||
"status": "preflight_only",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-gateway:ad903a7",
|
||||
"imageTag": "ad903a7",
|
||||
"digest": "not_published",
|
||||
"repositoryDigest": null,
|
||||
"runtimeKind": "node-command",
|
||||
"implementationState": "repo-entrypoint",
|
||||
"entrypoint": "cmd/hwlab-gateway/main.mjs",
|
||||
"localImageId": "sha256:ea0301dd293a71de84642b1d21682e8575219e08c221ccfb637bef7a1ce39c52"
|
||||
"entrypoint": "cmd/hwlab-gateway/main.mjs"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-gateway-simu",
|
||||
"status": "published",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:1e8d009",
|
||||
"imageTag": "1e8d009",
|
||||
"digest": "sha256:8d54ea760cdad2a418660e7d6293ae493d86b2173db0ec0be00ad49d63bb7aaf",
|
||||
"repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-gateway-simu@sha256:8d54ea760cdad2a418660e7d6293ae493d86b2173db0ec0be00ad49d63bb7aaf",
|
||||
"status": "preflight_only",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:ad903a7",
|
||||
"imageTag": "ad903a7",
|
||||
"digest": "not_published",
|
||||
"repositoryDigest": null,
|
||||
"runtimeKind": "node-command",
|
||||
"implementationState": "repo-entrypoint",
|
||||
"entrypoint": "cmd/hwlab-gateway-simu/main.mjs",
|
||||
"localImageId": "sha256:38736ba8ca348d8c7527a94378b509be879ce338f36596be91f756c88a78ff96"
|
||||
"entrypoint": "cmd/hwlab-gateway-simu/main.mjs"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-box-simu",
|
||||
"status": "published",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-box-simu:1e8d009",
|
||||
"imageTag": "1e8d009",
|
||||
"digest": "sha256:e18223e56d7b443dd0d5b3bccbd1ebc7964ef188c39d85750c1d82659634d861",
|
||||
"repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-box-simu@sha256:e18223e56d7b443dd0d5b3bccbd1ebc7964ef188c39d85750c1d82659634d861",
|
||||
"status": "preflight_only",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-box-simu:ad903a7",
|
||||
"imageTag": "ad903a7",
|
||||
"digest": "not_published",
|
||||
"repositoryDigest": null,
|
||||
"runtimeKind": "node-command",
|
||||
"implementationState": "repo-entrypoint",
|
||||
"entrypoint": "cmd/hwlab-box-simu/main.mjs",
|
||||
"localImageId": "sha256:7e9db37e7ad184c8fea659675385dc5dc1078c29e4108cf9e605495699570c8c"
|
||||
"entrypoint": "cmd/hwlab-box-simu/main.mjs"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-patch-panel",
|
||||
"status": "published",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:1e8d009",
|
||||
"imageTag": "1e8d009",
|
||||
"digest": "sha256:2cbf98f534cd31f86df9d656f40fb883bf40582dae4a4b3615c8c0db187d85fc",
|
||||
"repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-patch-panel@sha256:2cbf98f534cd31f86df9d656f40fb883bf40582dae4a4b3615c8c0db187d85fc",
|
||||
"status": "preflight_only",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:ad903a7",
|
||||
"imageTag": "ad903a7",
|
||||
"digest": "not_published",
|
||||
"repositoryDigest": null,
|
||||
"runtimeKind": "node-command",
|
||||
"implementationState": "repo-entrypoint",
|
||||
"entrypoint": "cmd/hwlab-patch-panel/main.mjs",
|
||||
"localImageId": "sha256:b7d11cf5683a3fc1a54029ecfd13bbf05353a33b2038ffa8a968f596c54bc864"
|
||||
"entrypoint": "cmd/hwlab-patch-panel/main.mjs"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-router",
|
||||
"status": "blocked_missing_runtime",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-router:1e8d009",
|
||||
"imageTag": "1e8d009",
|
||||
"status": "preflight_only",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-router:ad903a7",
|
||||
"imageTag": "ad903a7",
|
||||
"digest": "not_published",
|
||||
"repositoryDigest": null,
|
||||
"runtimeKind": "health-placeholder",
|
||||
@@ -203,9 +224,9 @@
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-tunnel-client",
|
||||
"status": "blocked_missing_runtime",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:1e8d009",
|
||||
"imageTag": "1e8d009",
|
||||
"status": "preflight_only",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:ad903a7",
|
||||
"imageTag": "ad903a7",
|
||||
"digest": "not_published",
|
||||
"repositoryDigest": null,
|
||||
"runtimeKind": "health-placeholder",
|
||||
@@ -214,9 +235,9 @@
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-edge-proxy",
|
||||
"status": "blocked_missing_runtime",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:1e8d009",
|
||||
"imageTag": "1e8d009",
|
||||
"status": "preflight_only",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:ad903a7",
|
||||
"imageTag": "ad903a7",
|
||||
"digest": "not_published",
|
||||
"repositoryDigest": null,
|
||||
"runtimeKind": "health-placeholder",
|
||||
@@ -225,29 +246,35 @@
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-cli",
|
||||
"status": "blocked_missing_runtime",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-cli:1e8d009",
|
||||
"imageTag": "1e8d009",
|
||||
"status": "preflight_only",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-cli:ad903a7",
|
||||
"imageTag": "ad903a7",
|
||||
"digest": "not_published",
|
||||
"repositoryDigest": null,
|
||||
"runtimeKind": "cli",
|
||||
"implementationState": "library-only",
|
||||
"entrypoint": "tools/hwlab-cli/lib/cli.mjs"
|
||||
"implementationState": "repo-entrypoint",
|
||||
"entrypoint": "tools/hwlab-cli/bin/hwlab-cli.mjs"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-agent-skills",
|
||||
"status": "published",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:1e8d009",
|
||||
"imageTag": "1e8d009",
|
||||
"digest": "sha256:46c3c8e24c3f696931599e40c21c66de98f1caf431a992f0a02e19dde67529b2",
|
||||
"repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-agent-skills@sha256:46c3c8e24c3f696931599e40c21c66de98f1caf431a992f0a02e19dde67529b2",
|
||||
"status": "preflight_only",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:ad903a7",
|
||||
"imageTag": "ad903a7",
|
||||
"digest": "not_published",
|
||||
"repositoryDigest": null,
|
||||
"runtimeKind": "skills-bundle",
|
||||
"implementationState": "repo-bundle",
|
||||
"entrypoint": "skills/hwlab-agent-runtime/SKILL.md",
|
||||
"localImageId": "sha256:33d64850cc7dd4b86ca136ba7b8d6ff79cdffab1d30f2c90cd16693976739050"
|
||||
"entrypoint": "skills/hwlab-agent-runtime/SKILL.md"
|
||||
}
|
||||
],
|
||||
"blockers": [
|
||||
{
|
||||
"type": "environment_blocker",
|
||||
"scope": "base-image",
|
||||
"status": "open",
|
||||
"summary": "HWLAB_DEV_BASE_IMAGE is not set and no local node:20-* image was found in the Docker image cache.",
|
||||
"next": "Set HWLAB_DEV_BASE_IMAGE to an approved local DEV builder base image, or preload a local node:20-* image such as node:20-bookworm-slim. Rerun this preflight and require status=ready before #35 artifact publish. Do not use UniDesk runtime, Code Queue runner, backend-core, provider-gateway, or microservice-proxy images as substitutes."
|
||||
},
|
||||
{
|
||||
"type": "runtime_blocker",
|
||||
"scope": "hwlab-router",
|
||||
@@ -268,13 +295,6 @@
|
||||
"status": "open",
|
||||
"summary": "hwlab-edge-proxy has no cmd/hwlab-edge-proxy/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation.",
|
||||
"next": "Add cmd/hwlab-edge-proxy/main.mjs or a dedicated Dockerfile for hwlab-edge-proxy."
|
||||
},
|
||||
{
|
||||
"type": "runtime_blocker",
|
||||
"scope": "hwlab-cli",
|
||||
"status": "open",
|
||||
"summary": "tools/hwlab-cli declares bin/hwlab-cli.mjs but the bin file is absent; a health-only placeholder image was not built as a real CLI artifact.",
|
||||
"next": "Add tools/hwlab-cli/bin/hwlab-cli.mjs that calls tools/hwlab-cli/lib/cli.mjs."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -12,11 +12,12 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs";
|
||||
import { runDevBaseImagePreflight } from "./src/dev-base-image-preflight.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const defaultRegistryPrefix =
|
||||
process.env.HWLAB_DEV_REGISTRY_PREFIX || process.env.HWLAB_DEV_REGISTRY || "127.0.0.1:5000/hwlab";
|
||||
const defaultBaseImage = process.env.HWLAB_DEV_BASE_IMAGE || "node:22-bookworm-slim";
|
||||
const defaultBaseImage = process.env.HWLAB_DEV_BASE_IMAGE || null;
|
||||
const defaultReportPath = "reports/dev-gate/dev-artifacts.json";
|
||||
const catalogPath = "deploy/artifact-catalog.dev.json";
|
||||
const deployPath = "deploy/deploy.json";
|
||||
@@ -93,7 +94,7 @@ function printHelp() {
|
||||
"",
|
||||
"options:",
|
||||
" --registry-prefix PREFIX default: 127.0.0.1:5000/hwlab",
|
||||
" --base-image IMAGE default: node:22-bookworm-slim",
|
||||
" --base-image IMAGE override HWLAB_DEV_BASE_IMAGE for this run; must already be local",
|
||||
" --services LIST comma-separated service IDs; default: all frozen DEV services",
|
||||
" --report PATH default: reports/dev-gate/dev-artifacts.json",
|
||||
" --no-report print JSON without updating the report file"
|
||||
@@ -127,6 +128,44 @@ function blocker({ type, scope, summary, next }) {
|
||||
};
|
||||
}
|
||||
|
||||
function preflightEnvForBaseImage(baseImage) {
|
||||
if (!baseImage) {
|
||||
return process.env;
|
||||
}
|
||||
return {
|
||||
...process.env,
|
||||
HWLAB_DEV_BASE_IMAGE: baseImage
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeBaseImagePreflight(result) {
|
||||
return {
|
||||
status: result.status,
|
||||
imageSource: result.imageSource,
|
||||
requestedImage: result.requestedImage,
|
||||
localTag: result.localTag,
|
||||
imageId: result.imageId,
|
||||
repoDigests: result.repoDigests,
|
||||
publishUsable: result.publishUsable,
|
||||
blockers: result.blockers,
|
||||
nextSteps: result.nextSteps,
|
||||
containerEngine: result.containerEngine
|
||||
};
|
||||
}
|
||||
|
||||
function baseImagePreflightBlocker(result) {
|
||||
return blocker({
|
||||
type: "environment_blocker",
|
||||
scope: "base-image",
|
||||
summary: result.blockers.length
|
||||
? result.blockers.join("; ")
|
||||
: "DEV builder base image preflight did not return ready.",
|
||||
next: result.nextSteps.length
|
||||
? result.nextSteps.join(" ")
|
||||
: "Provide an approved local Node 20 or HWLAB DEV builder base image before artifact publish."
|
||||
});
|
||||
}
|
||||
|
||||
function commandLine(command, args) {
|
||||
return [command, ...args].map((part) => (/\s/u.test(part) ? JSON.stringify(part) : part)).join(" ");
|
||||
}
|
||||
@@ -460,10 +499,16 @@ function tailText(value, maxLength = 2500) {
|
||||
return text.slice(text.length - maxLength);
|
||||
}
|
||||
|
||||
async function preflight({ args, services, catalog, deployManifest }) {
|
||||
async function preflight({ args, services, catalog, deployManifest, baseImagePreflight }) {
|
||||
const blockers = [];
|
||||
let registryPrefix = args.registryPrefix;
|
||||
|
||||
if (baseImagePreflight.publishUsable) {
|
||||
args.baseImage = baseImagePreflight.localTag;
|
||||
} else {
|
||||
blockers.push(baseImagePreflightBlocker(baseImagePreflight));
|
||||
}
|
||||
|
||||
try {
|
||||
registryPrefix = validateRegistryPrefix(args.registryPrefix);
|
||||
} catch (error) {
|
||||
@@ -490,7 +535,8 @@ async function preflight({ args, services, catalog, deployManifest }) {
|
||||
);
|
||||
}
|
||||
|
||||
for (const tag of [args.baseImage.split(":").at(-1), args.services.length === 1 ? args.services[0] : ""]) {
|
||||
const baseImageTag = args.baseImage ? args.baseImage.split(":").at(-1) : "";
|
||||
for (const tag of [baseImageTag, args.services.length === 1 ? args.services[0] : ""]) {
|
||||
if (mutableTags.has(tag)) {
|
||||
blockers.push(
|
||||
blocker({
|
||||
@@ -503,29 +549,7 @@ async function preflight({ args, services, catalog, deployManifest }) {
|
||||
}
|
||||
}
|
||||
|
||||
const dockerVersion = await run("docker", ["version", "--format", "{{json .}}"]);
|
||||
if (dockerVersion.code !== 0) {
|
||||
blockers.push(
|
||||
blocker({
|
||||
type: "environment_blocker",
|
||||
scope: "docker",
|
||||
summary: "Docker CLI or daemon is unavailable.",
|
||||
next: "Make the D601 runner Docker daemon reachable, then rerun the publish command."
|
||||
})
|
||||
);
|
||||
} else {
|
||||
const baseInspect = await run("docker", ["image", "inspect", args.baseImage, "--format", "{{.Id}}"]);
|
||||
if (baseInspect.code !== 0) {
|
||||
blockers.push(
|
||||
blocker({
|
||||
type: "environment_blocker",
|
||||
scope: "base-image",
|
||||
summary: `base image ${args.baseImage} is not present locally`,
|
||||
next: "Preload an internal/local Node base image or set HWLAB_DEV_BASE_IMAGE to an already-local image; the publish path uses --pull=false."
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (baseImagePreflight.containerEngine?.available) {
|
||||
if (registryPrefix.startsWith("127.0.0.1:5000/") || registryPrefix.startsWith("localhost:5000/")) {
|
||||
const ps = await run("docker", ["ps", "--format", "{{json .}}"]);
|
||||
if (ps.code !== 0 || !dockerPsShowsRegistry5000(ps.stdout)) {
|
||||
@@ -755,7 +779,7 @@ function devGateBlockers(blockers) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function createReport({ args, repo, commitId, shortCommit, mode, artifacts, blockers }) {
|
||||
function createReport({ args, repo, commitId, shortCommit, mode, artifacts, blockers, baseImagePreflight }) {
|
||||
const status = reportStatus(mode, artifacts, blockers);
|
||||
const fatalBlocked = hasFatalBlocker(blockers);
|
||||
const publishedCount = artifacts.filter((artifact) => artifact.status === "published").length;
|
||||
@@ -777,12 +801,16 @@ function createReport({ args, repo, commitId, shortCommit, mode, artifacts, bloc
|
||||
"docs/dev-acceptance-matrix.md",
|
||||
"docs/m0-contract-audit.md",
|
||||
"docs/artifact-catalog.md",
|
||||
"docs/dev-artifact-publish.md"
|
||||
"docs/dev-artifact-publish.md",
|
||||
"docs/dev-base-image-preflight.md"
|
||||
],
|
||||
summary: "DEV artifact publish evidence for pikasTech/HWLAB#35, stored in the DEV gate report envelope."
|
||||
},
|
||||
validationCommands: [
|
||||
"node --check scripts/dev-artifact-publish.mjs",
|
||||
"node --check scripts/preflight-dev-base-image.mjs",
|
||||
"node --check scripts/src/dev-base-image-preflight.mjs",
|
||||
"node scripts/preflight-dev-base-image.mjs",
|
||||
"node scripts/dev-artifact-publish.mjs --preflight --no-report",
|
||||
"node --check scripts/validate-dev-gate-report.mjs",
|
||||
"node scripts/validate-dev-gate-report.mjs"
|
||||
@@ -811,6 +839,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, artifacts, bloc
|
||||
status: fatalBlocked ? "blocked" : "pass",
|
||||
requirements: [
|
||||
"Registry prefix is localhost or private/internal only.",
|
||||
"DEV builder base image preflight returns ready before build or publish.",
|
||||
"Environment remains dev and namespace remains hwlab-dev.",
|
||||
"PROD profile remains disabled.",
|
||||
"No secret or token material is required by this workflow."
|
||||
@@ -827,7 +856,8 @@ function createReport({ args, repo, commitId, shortCommit, mode, artifacts, bloc
|
||||
repo,
|
||||
sourceCommitId: commitId,
|
||||
registryPrefix: args.registryPrefix,
|
||||
baseImage: args.baseImage,
|
||||
baseImage: args.baseImage ?? null,
|
||||
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
|
||||
environment: ENVIRONMENT_DEV,
|
||||
serviceCount: artifacts.length,
|
||||
builtCount,
|
||||
@@ -865,6 +895,12 @@ async function main() {
|
||||
}
|
||||
|
||||
args.registryPrefix = validateRegistryPrefix(args.registryPrefix);
|
||||
const baseImagePreflight = await runDevBaseImagePreflight({
|
||||
env: preflightEnvForBaseImage(args.baseImage)
|
||||
});
|
||||
if (baseImagePreflight.publishUsable) {
|
||||
args.baseImage = baseImagePreflight.localTag;
|
||||
}
|
||||
const [catalog, deployManifest, commitId, remoteUrl] = await Promise.all([
|
||||
readJson(catalogPath),
|
||||
readJson(deployPath),
|
||||
@@ -875,7 +911,7 @@ async function main() {
|
||||
const repo = repoLabelFromRemote(remoteUrl);
|
||||
const services = await resolveServices(args.services);
|
||||
|
||||
let blockers = await preflight({ args, services, catalog, deployManifest });
|
||||
let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight });
|
||||
let artifacts = services.map((service) => ({
|
||||
...service,
|
||||
status: "preflight_only",
|
||||
@@ -912,7 +948,7 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, artifacts, blockers });
|
||||
const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, artifacts, blockers, baseImagePreflight });
|
||||
if (args.emitReport) {
|
||||
await writeReport(args.reportPath, report);
|
||||
}
|
||||
@@ -923,6 +959,7 @@ async function main() {
|
||||
reportPath: args.emitReport ? args.reportPath : null,
|
||||
sourceCommitId: commitId,
|
||||
registryPrefix: args.registryPrefix,
|
||||
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
|
||||
services: report.artifactPublish.services.map((service) => ({
|
||||
serviceId: service.serviceId,
|
||||
status: service.status,
|
||||
@@ -932,7 +969,9 @@ async function main() {
|
||||
blockers
|
||||
}, null, 2));
|
||||
|
||||
if ((args.mode === "build" || args.mode === "publish") && artifacts.some((artifact) => artifact.status.endsWith("_failed"))) {
|
||||
if (hasFatalBlocker(blockers)) {
|
||||
process.exitCode = 2;
|
||||
} else if ((args.mode === "build" || args.mode === "publish") && artifacts.some((artifact) => artifact.status.endsWith("_failed"))) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
import {
|
||||
exitCodeForPreflight,
|
||||
formatPreflightResult,
|
||||
runDevBaseImagePreflight,
|
||||
unexpectedPreflightErrorResult
|
||||
} from "./src/dev-base-image-preflight.mjs";
|
||||
|
||||
try {
|
||||
const result = await runDevBaseImagePreflight({ env: process.env });
|
||||
console.log(formatPreflightResult(result));
|
||||
process.exitCode = exitCodeForPreflight(result);
|
||||
} catch (error) {
|
||||
const result = unexpectedPreflightErrorResult(error);
|
||||
console.log(formatPreflightResult(result));
|
||||
process.exitCode = exitCodeForPreflight(result);
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs";
|
||||
|
||||
const envVarName = "HWLAB_DEV_BASE_IMAGE";
|
||||
const dockerCommand = "docker";
|
||||
const dockerTimeoutMs = 15_000;
|
||||
const node20TagPattern = /^20-/u;
|
||||
const forbiddenImageFragments = Object.freeze([
|
||||
"unidesk",
|
||||
"backend-core",
|
||||
"provider-gateway",
|
||||
"microservice-proxy",
|
||||
"code-queue"
|
||||
]);
|
||||
const allowedExplicitBaseImages = Object.freeze([
|
||||
"node:20-*",
|
||||
"hwlab-dev-base:*",
|
||||
"*/hwlab-dev-base:*",
|
||||
"hwlab-node20-base:*",
|
||||
"*/hwlab-node20-base:*"
|
||||
]);
|
||||
|
||||
function runDocker(args) {
|
||||
const result = spawnSync(dockerCommand, args, {
|
||||
encoding: "utf8",
|
||||
timeout: dockerTimeoutMs
|
||||
});
|
||||
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
errorCode: result.error?.code ?? null,
|
||||
stdout: result.stdout?.trim() ?? "",
|
||||
stderr: result.stderr?.trim() ?? "",
|
||||
timedOut: result.error?.code === "ETIMEDOUT"
|
||||
};
|
||||
}
|
||||
|
||||
function splitJsonLines(stdout) {
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
function candidateTag(image) {
|
||||
return `${image.Repository}:${image.Tag}`;
|
||||
}
|
||||
|
||||
function imageRefFromInspect(requestedImage, inspectDoc) {
|
||||
const repoTags = Array.isArray(inspectDoc.RepoTags) ? inspectDoc.RepoTags : [];
|
||||
if (repoTags.includes(requestedImage)) {
|
||||
return requestedImage;
|
||||
}
|
||||
return repoTags.find((tag) => !tag.includes("<none>")) ?? requestedImage;
|
||||
}
|
||||
|
||||
function isNode20BaseRef(imageRef) {
|
||||
const lowerRef = imageRef.toLowerCase();
|
||||
return lowerRef.startsWith("node:20-");
|
||||
}
|
||||
|
||||
function isHwlabDevBaseRef(imageRef) {
|
||||
const lowerRef = imageRef.toLowerCase();
|
||||
return /(^|\/)(hwlab-dev-base|hwlab-node20-base)(?=[:@]|$)/u.test(lowerRef);
|
||||
}
|
||||
|
||||
function approvalForBaseRef(imageRef) {
|
||||
if (isNode20BaseRef(imageRef)) {
|
||||
return {
|
||||
approved: true,
|
||||
reason: "local Node 20 builder base image"
|
||||
};
|
||||
}
|
||||
if (isHwlabDevBaseRef(imageRef)) {
|
||||
return {
|
||||
approved: true,
|
||||
reason: "explicit HWLAB DEV builder base image"
|
||||
};
|
||||
}
|
||||
return {
|
||||
approved: false,
|
||||
reason: `image reference is not one of the approved builder base patterns: ${allowedExplicitBaseImages.join(", ")}`
|
||||
};
|
||||
}
|
||||
|
||||
function forbiddenReasons(imageRef) {
|
||||
const lowerRef = imageRef.toLowerCase();
|
||||
const reasons = [];
|
||||
|
||||
for (const fragment of forbiddenImageFragments) {
|
||||
if (lowerRef.includes(fragment)) {
|
||||
reasons.push(`image reference contains forbidden runtime/build substitute fragment: ${fragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const serviceId of SERVICE_IDS) {
|
||||
if (
|
||||
lowerRef.includes(`/pikastech/${serviceId}:`) ||
|
||||
lowerRef.includes(`/pikastech/${serviceId}@`) ||
|
||||
lowerRef.startsWith(`${serviceId}:`) ||
|
||||
lowerRef.startsWith(`${serviceId}@`)
|
||||
) {
|
||||
reasons.push(`image reference points at HWLAB service artifact ${serviceId}, not a builder base image`);
|
||||
}
|
||||
}
|
||||
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function inspectEvidenceStrings(inspectDoc) {
|
||||
const labels = inspectDoc.Config?.Labels ?? {};
|
||||
const env = Array.isArray(inspectDoc.Config?.Env) ? inspectDoc.Config.Env : [];
|
||||
return [
|
||||
...(Array.isArray(inspectDoc.RepoTags) ? inspectDoc.RepoTags : []),
|
||||
...(Array.isArray(inspectDoc.RepoDigests) ? inspectDoc.RepoDigests : []),
|
||||
...Object.entries(labels).map(([key, value]) => `${key}=${value}`),
|
||||
...env
|
||||
].filter((value) => typeof value === "string" && value.length > 0);
|
||||
}
|
||||
|
||||
function inspectEvidenceBlockers(inspectDoc) {
|
||||
const blockers = [];
|
||||
for (const evidence of inspectEvidenceStrings(inspectDoc)) {
|
||||
for (const reason of forbiddenReasons(evidence)) {
|
||||
blockers.push(`${reason} in image inspect evidence ${JSON.stringify(evidence)}`);
|
||||
}
|
||||
}
|
||||
return [...new Set(blockers)];
|
||||
}
|
||||
|
||||
function baseResult(overrides) {
|
||||
return {
|
||||
preflight: "hwlab-dev-base-image",
|
||||
environment: ENVIRONMENT_DEV,
|
||||
status: "blocked",
|
||||
publishUsable: false,
|
||||
artifactPublish: {
|
||||
issue: "#35",
|
||||
usable: false,
|
||||
reason: "No approved local DEV builder base image was selected."
|
||||
},
|
||||
imageSource: "none",
|
||||
requestedImage: null,
|
||||
localTag: null,
|
||||
imageId: null,
|
||||
repoDigests: [],
|
||||
candidates: [],
|
||||
blockers: [],
|
||||
nextSteps: [],
|
||||
constraints: {
|
||||
localOnly: true,
|
||||
doesNotPull: true,
|
||||
doesNotBuild: true,
|
||||
doesNotPush: true,
|
||||
doesNotDeploy: true,
|
||||
doesNotReadSecrets: true,
|
||||
allowedExplicitBaseImages,
|
||||
forbiddenImageFragments,
|
||||
forbiddenHwlabServiceImages: SERVICE_IDS
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function blocked(overrides) {
|
||||
return baseResult({
|
||||
status: "blocked",
|
||||
publishUsable: false,
|
||||
artifactPublish: {
|
||||
issue: "#35",
|
||||
usable: false,
|
||||
reason: "DEV artifact publish must stop until the blocker is resolved."
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
function ready({ imageSource, approval, requestedImage, localTag, inspectDoc, candidates }) {
|
||||
return baseResult({
|
||||
status: "ready",
|
||||
publishUsable: true,
|
||||
artifactPublish: {
|
||||
issue: "#35",
|
||||
usable: true,
|
||||
reason: "A local approved DEV builder base image is available."
|
||||
},
|
||||
imageSource,
|
||||
approval,
|
||||
requestedImage,
|
||||
localTag,
|
||||
imageId: inspectDoc.Id ?? null,
|
||||
repoDigests: Array.isArray(inspectDoc.RepoDigests) ? inspectDoc.RepoDigests : [],
|
||||
candidates,
|
||||
blockers: [],
|
||||
nextSteps: [
|
||||
"Use the reported localTag as the DEV builder base image for artifact publish.",
|
||||
"Keep publish DEV-only and record the produced HWLAB service image tags and digests separately."
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function inspectLocalImage(imageRef) {
|
||||
const inspect = runDocker(["image", "inspect", imageRef]);
|
||||
if (!inspect.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
inspect,
|
||||
docs: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
inspect,
|
||||
docs: JSON.parse(inspect.stdout)
|
||||
};
|
||||
}
|
||||
|
||||
function dockerUnavailableResult(versionResult) {
|
||||
const reason = versionResult.timedOut
|
||||
? "Docker CLI timed out during version detection."
|
||||
: "Docker CLI is not available in this runner.";
|
||||
return blocked({
|
||||
blockers: [reason],
|
||||
nextSteps: [
|
||||
"Run this preflight in the D601 builder environment with Docker available.",
|
||||
`Preload an approved local Node 20 builder image and set ${envVarName}=<local-image> when using a non-node:20-* tag.`,
|
||||
"Do not use a UniDesk, Code Queue, backend-core, provider-gateway, or microservice-proxy runtime image as a substitute."
|
||||
],
|
||||
containerEngine: {
|
||||
command: dockerCommand,
|
||||
available: false,
|
||||
version: null,
|
||||
errorCode: versionResult.errorCode,
|
||||
stderr: versionResult.stderr
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function selectNode20Candidate(images) {
|
||||
const candidates = images
|
||||
.filter((image) => image.Repository === "node" && node20TagPattern.test(image.Tag))
|
||||
.map((image) => ({
|
||||
source: "local-docker-node:20-*",
|
||||
approval: approvalForBaseRef(candidateTag(image)),
|
||||
localTag: candidateTag(image),
|
||||
imageId: image.ID ?? null,
|
||||
size: image.Size ?? null,
|
||||
createdSince: image.CreatedSince ?? null
|
||||
}))
|
||||
.sort((left, right) => left.localTag.localeCompare(right.localTag));
|
||||
|
||||
return {
|
||||
candidates,
|
||||
selected: candidates[0] ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function explicitImageBlockers(requestedImage, localTag) {
|
||||
const approval = approvalForBaseRef(localTag);
|
||||
const requestedApproval = approvalForBaseRef(requestedImage);
|
||||
if (approval.approved || requestedApproval.approved) {
|
||||
return {
|
||||
approval: approval.approved ? approval : requestedApproval,
|
||||
blockers: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
approval,
|
||||
blockers: [approval.reason]
|
||||
};
|
||||
}
|
||||
|
||||
function readyFromExplicitImage({ requestedImage, inspectDoc, containerEngine }) {
|
||||
const localTag = imageRefFromInspect(requestedImage, inspectDoc);
|
||||
const localTagBlockers = forbiddenReasons(localTag);
|
||||
const evidenceBlockers = inspectEvidenceBlockers(inspectDoc);
|
||||
const localTagApproval = explicitImageBlockers(requestedImage, localTag);
|
||||
const blockers = [...localTagBlockers, ...evidenceBlockers, ...localTagApproval.blockers];
|
||||
if (blockers.length > 0) {
|
||||
return blocked({
|
||||
imageSource: `env:${envVarName}`,
|
||||
requestedImage,
|
||||
localTag,
|
||||
imageId: inspectDoc.Id ?? null,
|
||||
blockers,
|
||||
nextSteps: [
|
||||
`Set ${envVarName} to an approved local builder base image, not a runtime service image.`,
|
||||
"Allowed references are local node:20-* tags or HWLAB dev base tags such as hwlab-dev-base:<tag>.",
|
||||
"Do not continue #35 artifact publish with this image."
|
||||
],
|
||||
containerEngine
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...ready({
|
||||
imageSource: `env:${envVarName}`,
|
||||
approval: localTagApproval.approval,
|
||||
requestedImage,
|
||||
localTag,
|
||||
inspectDoc,
|
||||
candidates: [
|
||||
{
|
||||
source: `env:${envVarName}`,
|
||||
approval: localTagApproval.approval,
|
||||
localTag,
|
||||
imageId: inspectDoc.Id ?? null
|
||||
}
|
||||
]
|
||||
}),
|
||||
containerEngine
|
||||
};
|
||||
}
|
||||
|
||||
export async function runDevBaseImagePreflight({ env = process.env } = {}) {
|
||||
const requestedImage = env[envVarName]?.trim() || null;
|
||||
const version = runDocker(["--version"]);
|
||||
if (!version.ok) {
|
||||
return dockerUnavailableResult(version);
|
||||
}
|
||||
|
||||
const containerEngine = {
|
||||
command: dockerCommand,
|
||||
available: true,
|
||||
version: version.stdout,
|
||||
errorCode: null,
|
||||
stderr: ""
|
||||
};
|
||||
|
||||
if (requestedImage) {
|
||||
const refBlockers = forbiddenReasons(requestedImage);
|
||||
const requestedApproval = approvalForBaseRef(requestedImage);
|
||||
if (refBlockers.length > 0 || !requestedApproval.approved) {
|
||||
return blocked({
|
||||
imageSource: `env:${envVarName}`,
|
||||
requestedImage,
|
||||
blockers: [
|
||||
...refBlockers,
|
||||
...(requestedApproval.approved ? [] : [requestedApproval.reason])
|
||||
],
|
||||
nextSteps: [
|
||||
`Set ${envVarName} to an approved local builder base image, not a runtime service image.`,
|
||||
"Allowed references are local node:20-* tags or HWLAB dev base tags such as hwlab-dev-base:<tag>.",
|
||||
"Do not continue #35 artifact publish with this image."
|
||||
],
|
||||
containerEngine
|
||||
});
|
||||
}
|
||||
|
||||
const inspected = inspectLocalImage(requestedImage);
|
||||
if (!inspected.ok || inspected.docs.length === 0) {
|
||||
return blocked({
|
||||
imageSource: `env:${envVarName}`,
|
||||
requestedImage,
|
||||
blockers: [`${envVarName} is set to ${requestedImage}, but that image is not present in the local Docker image cache.`],
|
||||
nextSteps: [
|
||||
"Preload or build the approved DEV builder base image locally before artifact publish.",
|
||||
`Keep ${envVarName} pointed at the exact local tag once the image is available.`,
|
||||
"Do not pull from third-party hosting or substitute a UniDesk runtime image during this preflight."
|
||||
],
|
||||
containerEngine
|
||||
});
|
||||
}
|
||||
|
||||
return readyFromExplicitImage({
|
||||
requestedImage,
|
||||
inspectDoc: inspected.docs[0],
|
||||
containerEngine
|
||||
});
|
||||
}
|
||||
|
||||
const imageList = runDocker(["image", "ls", "--format", "{{json .}}"]);
|
||||
if (!imageList.ok) {
|
||||
return blocked({
|
||||
blockers: ["Docker image list failed; local base-image cache could not be inspected."],
|
||||
nextSteps: [
|
||||
"Start or repair the local Docker daemon for the D601 builder environment.",
|
||||
`Then set ${envVarName}=<approved-local-image> or provide a local node:20-* image.`,
|
||||
"Do not claim #35 artifact publish readiness until this preflight returns ready."
|
||||
],
|
||||
containerEngine: {
|
||||
...containerEngine,
|
||||
stderr: imageList.stderr,
|
||||
errorCode: imageList.errorCode
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { candidates, selected } = selectNode20Candidate(splitJsonLines(imageList.stdout));
|
||||
if (!selected) {
|
||||
return blocked({
|
||||
blockers: [`${envVarName} is not set and no local node:20-* image was found in the Docker image cache.`],
|
||||
candidates,
|
||||
nextSteps: [
|
||||
`Set ${envVarName} to an approved local DEV builder base image, or preload a local node:20-* image such as node:20-bookworm-slim.`,
|
||||
"Rerun this preflight and require status=ready before #35 artifact publish.",
|
||||
"Do not use UniDesk runtime, Code Queue runner, backend-core, provider-gateway, or microservice-proxy images as substitutes."
|
||||
],
|
||||
containerEngine
|
||||
});
|
||||
}
|
||||
|
||||
const inspected = inspectLocalImage(selected.localTag);
|
||||
if (!inspected.ok || inspected.docs.length === 0) {
|
||||
return blocked({
|
||||
imageSource: selected.source,
|
||||
requestedImage: null,
|
||||
localTag: selected.localTag,
|
||||
candidates,
|
||||
blockers: [`Selected local candidate ${selected.localTag} could not be inspected.`],
|
||||
nextSteps: [
|
||||
`Set ${envVarName} to a known-good approved local base image and rerun this preflight.`,
|
||||
"Do not continue #35 artifact publish until image inspect succeeds."
|
||||
],
|
||||
containerEngine
|
||||
});
|
||||
}
|
||||
const evidenceBlockers = inspectEvidenceBlockers(inspected.docs[0]);
|
||||
if (evidenceBlockers.length > 0) {
|
||||
return blocked({
|
||||
imageSource: selected.source,
|
||||
requestedImage: null,
|
||||
localTag: selected.localTag,
|
||||
candidates,
|
||||
imageId: inspected.docs[0].Id ?? null,
|
||||
blockers: evidenceBlockers,
|
||||
nextSteps: [
|
||||
`Replace ${selected.localTag} with a clean approved Node 20 builder base image.`,
|
||||
"Do not continue #35 artifact publish with an image whose inspect metadata points at a forbidden runtime substitute."
|
||||
],
|
||||
containerEngine
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...ready({
|
||||
imageSource: selected.source,
|
||||
approval: selected.approval,
|
||||
requestedImage: null,
|
||||
localTag: selected.localTag,
|
||||
inspectDoc: inspected.docs[0],
|
||||
candidates
|
||||
}),
|
||||
containerEngine
|
||||
};
|
||||
}
|
||||
|
||||
export function unexpectedPreflightErrorResult(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return blocked({
|
||||
blockers: [`Unexpected preflight error: ${message}`],
|
||||
nextSteps: [
|
||||
"Treat this as a blocker and inspect the preflight command output.",
|
||||
"Do not continue #35 artifact publish until the preflight returns status=ready."
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
export function formatPreflightResult(result) {
|
||||
return JSON.stringify(result, null, 2);
|
||||
}
|
||||
|
||||
export function exitCodeForPreflight(result) {
|
||||
return result.publishUsable ? 0 : 2;
|
||||
}
|
||||
Reference in New Issue
Block a user