fix: split registry capability probes

Refs #66. Merged by commander after reviewing Code Queue task codex_1779422778882_1. Separates process HTTP, Docker daemon push, and k3s pull registry capabilities so runner loopback HTTP degradation does not block artifact publish when Docker/k3s paths are proven.
This commit is contained in:
Lyon
2026-05-22 12:46:59 +08:00
committed by GitHub
parent a1c3cb58da
commit 7e29522b65
9 changed files with 1977 additions and 385 deletions
+21 -3
View File
@@ -30,6 +30,7 @@ Static check:
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/refresh-artifact-catalog.mjs
```
@@ -89,7 +90,22 @@ 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, and the reason a service was not published.
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
@@ -120,8 +136,10 @@ 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, the report must remain `status: "blocked"`. Do not
turn a base-image or registry blocker into a fake digest or published state.
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.
## Known Implementation States
+26 -4
View File
@@ -15,6 +15,7 @@ Run from the repository root:
```sh
node --check scripts/dev-gate-preflight.mjs
node --check scripts/src/dev-gate-preflight.mjs
node --check scripts/src/registry-capabilities.mjs
node --check scripts/refresh-artifact-catalog.mjs
node scripts/dev-gate-preflight.mjs
```
@@ -48,6 +49,9 @@ The preflight checks:
blocked with `not_published` digests.
- `reports/dev-gate/dev-artifacts.json`, when present, proves all frozen DEV
service artifacts were published for the selected `origin/main` commit.
- Registry capability evidence is split into `process-http-access`,
`docker-daemon-push-access`, and `k3s-pull-access` instead of one ambiguous
"registry reachable" result.
- `deploy/k8s/base` and `deploy/k8s/dev` parse and remain scoped to
`hwlab-dev`.
- `hwlab-cloud-api` declares the DEV DB env contract:
@@ -62,17 +66,35 @@ The preflight checks:
- `reports/dev-gate/dev-edge-health.json`, when present, records read-only
public edge, frps, tunnel-health, and k3s observability evidence.
- `http://74.48.78.17:16667/health/live` responds.
- GHCR manifests are visible without reading credentials.
- Catalog image manifests are visible without reading credentials. This is
artifact catalog evidence and is separate from Docker daemon push access.
- Deploy images do not point at UniDesk, provider-gateway, or microservice-proxy
substitutes.
## Registry Capability Dimensions
`registryCapabilities` appears in both the preflight report and the artifact
publish report. The dimensions are:
- `process-http-access`: runner-process HTTP access to `/v2/`. A failed
`127.0.0.1` request can happen while Docker daemon push access still works,
so this dimension is diagnostic and may be `degraded` without blocking
publish.
- `docker-daemon-push-access`: read-only Docker daemon evidence for the
local/internal registry target. This is the publish-path capability.
- `k3s-pull-access`: read-only `kubectl` evidence for whether the DEV cluster
can inspect image pull state. This is the deploy-path capability.
## Verdict Rules
`ready` requires all checks to pass and no open blocker.
`blocked` is expected until the real DEV runtime path can be proven. Each
blocker includes a `type`, `scope`, `summary`, and `nextTask` so the next task is
the smallest repair needed before rerunning the preflight.
`degraded` means a non-publish-path capability is observable but incomplete,
such as runner-process loopback HTTP refusing `127.0.0.1:5000/v2/` while Docker
daemon registry visibility is still present. `blocked` is expected until the
real DEV runtime path can be proven. Each blocker includes a `type`, `scope`,
`summary`, and `nextTask` so the next task is the smallest repair needed before
rerunning the preflight.
The DB gate has two common blocked scopes:
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check",
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.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/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.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 --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node --test internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.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/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.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/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node --test internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
"dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
"cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs",
"m1:smoke": "node scripts/m1-contract-smoke.mjs",
+358 -3
View File
@@ -27,7 +27,8 @@
"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"
"node scripts/validate-dev-gate-report.mjs",
"node --check scripts/src/registry-capabilities.mjs"
],
"localSmoke": {
"status": "not_run",
@@ -526,7 +527,184 @@
"status": "published",
"notPublishedReason": null
}
]
],
"registryCapabilities": {
"version": "v1",
"registryPrefix": "127.0.0.1:5000/hwlab",
"generatedAt": "2026-05-22T04:26:32.593Z",
"classification": "degraded",
"dimensions": {
"processHttpAccess": {
"id": "process-http-access",
"status": "degraded",
"requiredForPublish": false,
"requiredForDeploy": false,
"endpoint": "http://127.0.0.1:5000/v2/",
"probeKind": "runner-process-http-v2",
"summary": "Runner process cannot reach http://127.0.0.1:5000/v2/; this is process HTTP access only and does not prove Docker daemon push failure.",
"evidence": {
"probe": {
"ok": false,
"url": "http://127.0.0.1:5000/v2/",
"method": "GET",
"error": "fetch failed ECONNREFUSED 127.0.0.1 5000"
},
"publishFailure": false
}
},
"dockerDaemonPushAccess": {
"id": "docker-daemon-push-access",
"status": "pass",
"requiredForPublish": true,
"requiredForDeploy": false,
"endpoint": "127.0.0.1:5000/hwlab",
"probeKind": "docker-ps-read-only",
"summary": "Docker daemon can see a local/internal registry target for publish preflight purposes; no push was attempted.",
"evidence": {
"dockerVersion": {
"command": "docker --version",
"ok": true,
"exitCode": 0,
"stdout": "Docker version 26.1.5+dfsg1, build a72d7cd",
"stderr": "",
"error": null
},
"registryPrefix": "127.0.0.1:5000/hwlab",
"loopbackPort5000": true,
"registryContainerVisible": true,
"matchingContainers": [
{
"id": "00091efce380",
"image": "registry:2.8.3",
"names": "unidesk-artifact-registry",
"ports": "127.0.0.1:5000->5000/tcp",
"state": "running",
"status": "Up 27 hours"
}
],
"pushAttempted": false,
"mutationAttempted": false
}
},
"k3sPullAccess": {
"id": "k3s-pull-access",
"status": "pass",
"requiredForPublish": false,
"requiredForDeploy": true,
"endpoint": "127.0.0.1:5000/hwlab",
"probeKind": "kubectl-read-only",
"summary": "Read-only kubectl observed hwlab-dev pods with pulled registry images.",
"evidence": {
"kubectlAvailable": true,
"context": {
"command": "kubectl config current-context",
"ok": true,
"exitCode": 0,
"stdout": "in-cluster",
"stderr": "",
"error": null
},
"canGetPods": {
"command": "kubectl auth can-i get pods -n hwlab-dev",
"ok": true,
"exitCode": 0,
"stdout": "yes",
"stderr": "",
"error": null
},
"podCount": 13,
"pulledImages": [
{
"pod": "hwlab-agent-mgr-75544c7747-5dzt9",
"container": "hwlab-agent-mgr",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-agent-mgr@sha256:c16c733306a28ae9385087653b79f5bb2f28724d917a89bb296646f6a02c5318"
},
{
"pod": "hwlab-agent-skills-849d9976f4-2nsmw",
"container": "hwlab-agent-skills",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-agent-skills@sha256:246fb7bb48597a7179aab62cb091e8213e47b71b6871ba94e6028032992e916c"
},
{
"pod": "hwlab-box-simu-55688cbcc7-pftxc",
"container": "hwlab-box-simu",
"image": "127.0.0.1:5000/hwlab/hwlab-box-simu:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-box-simu@sha256:ae5fcc06a276918561c2d752f2221156bd2bd4db7fb93967ae76af97bfdc51bf"
},
{
"pod": "hwlab-box-simu-55688cbcc7-sq5fx",
"container": "hwlab-box-simu",
"image": "127.0.0.1:5000/hwlab/hwlab-box-simu:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-box-simu@sha256:ae5fcc06a276918561c2d752f2221156bd2bd4db7fb93967ae76af97bfdc51bf"
},
{
"pod": "hwlab-cloud-api-f48dcdfb-9n6zh",
"container": "hwlab-cloud-api",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-cloud-api@sha256:3946efdd7360409cea6a897cb03a6f3172f14819d3132df3aab821621909513f"
},
{
"pod": "hwlab-cloud-web-cd9655b98-gxtmc",
"container": "hwlab-cloud-web",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-cloud-web@sha256:868e2a11c56cc1a27c77de3fa71a7b092f21ae98a4052104e9071bd93960abfa"
},
{
"pod": "hwlab-edge-proxy-567f978cdd-8fwc8",
"container": "hwlab-edge-proxy",
"image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-edge-proxy@sha256:384832fc579063b9b0a940973690734bb402607720fd36f4662e4ee6fa5a57d2"
},
{
"pod": "hwlab-frpc-59f57d479c-wm5l2",
"container": "frpc",
"image": "127.0.0.1:5000/hwlab/frpc:v0.68.1",
"imageID": "127.0.0.1:5000/hwlab/frpc@sha256:ce400cda42a260964e40ab1ca68b868f72c569cdce4b243aef36ce40194d287b"
},
{
"pod": "hwlab-gateway-simu-699fd486b-j9pk7",
"container": "hwlab-gateway-simu",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-gateway-simu@sha256:00599721b910b5667b6be43c416cab4c410c6cc29b7f56ecdfb6eff71123d01c"
},
{
"pod": "hwlab-gateway-simu-699fd486b-phcgp",
"container": "hwlab-gateway-simu",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-gateway-simu@sha256:00599721b910b5667b6be43c416cab4c410c6cc29b7f56ecdfb6eff71123d01c"
},
{
"pod": "hwlab-patch-panel-757f9f44d5-7np8r",
"container": "hwlab-patch-panel",
"image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-patch-panel@sha256:5b3aa6505492c2ed398d21cbd35a8754672114acd0fa97faa3351d4b6a6dfd5a"
},
{
"pod": "hwlab-router-5d9cbdf89b-p7r94",
"container": "hwlab-router",
"image": "127.0.0.1:5000/hwlab/hwlab-router:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-router@sha256:217b1fdb0f704c4183e9d8503644177f0c8bae674da0433985c19ef909f0e342"
},
{
"pod": "hwlab-tunnel-client-65c7858c8-wjqsh",
"container": "hwlab-tunnel-client",
"image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-tunnel-client@sha256:9bcdd8a58132d7bc4dbfc71297f802fc0ab407a1b5c1bb1ea58e0791147a243b"
}
],
"pullFailures": [],
"pullAttempted": false,
"mutationAttempted": false
}
}
},
"interpretation": {
"processHttpAccess": "Runner process HTTP access to /v2/ is diagnostic only and must not be treated as Docker daemon push access.",
"dockerDaemonPushAccess": "Docker daemon push access is the publish-path capability. This probe is read-only and does not push.",
"k3sPullAccess": "k3s pull access is the deploy-path capability. This probe is read-only and does not create pods or read secrets."
}
}
},
"serviceCount": 13,
"requiredServiceCount": 13,
@@ -757,7 +935,184 @@
"localImageId": "sha256:060596f374ddf14c756fffa3565f65a2178a29e805441fa8c00f92c1868e6be3"
}
],
"blockers": []
"blockers": [],
"registryCapabilities": {
"version": "v1",
"registryPrefix": "127.0.0.1:5000/hwlab",
"generatedAt": "2026-05-22T04:26:32.593Z",
"classification": "degraded",
"dimensions": {
"processHttpAccess": {
"id": "process-http-access",
"status": "degraded",
"requiredForPublish": false,
"requiredForDeploy": false,
"endpoint": "http://127.0.0.1:5000/v2/",
"probeKind": "runner-process-http-v2",
"summary": "Runner process cannot reach http://127.0.0.1:5000/v2/; this is process HTTP access only and does not prove Docker daemon push failure.",
"evidence": {
"probe": {
"ok": false,
"url": "http://127.0.0.1:5000/v2/",
"method": "GET",
"error": "fetch failed ECONNREFUSED 127.0.0.1 5000"
},
"publishFailure": false
}
},
"dockerDaemonPushAccess": {
"id": "docker-daemon-push-access",
"status": "pass",
"requiredForPublish": true,
"requiredForDeploy": false,
"endpoint": "127.0.0.1:5000/hwlab",
"probeKind": "docker-ps-read-only",
"summary": "Docker daemon can see a local/internal registry target for publish preflight purposes; no push was attempted.",
"evidence": {
"dockerVersion": {
"command": "docker --version",
"ok": true,
"exitCode": 0,
"stdout": "Docker version 26.1.5+dfsg1, build a72d7cd",
"stderr": "",
"error": null
},
"registryPrefix": "127.0.0.1:5000/hwlab",
"loopbackPort5000": true,
"registryContainerVisible": true,
"matchingContainers": [
{
"id": "00091efce380",
"image": "registry:2.8.3",
"names": "unidesk-artifact-registry",
"ports": "127.0.0.1:5000->5000/tcp",
"state": "running",
"status": "Up 27 hours"
}
],
"pushAttempted": false,
"mutationAttempted": false
}
},
"k3sPullAccess": {
"id": "k3s-pull-access",
"status": "pass",
"requiredForPublish": false,
"requiredForDeploy": true,
"endpoint": "127.0.0.1:5000/hwlab",
"probeKind": "kubectl-read-only",
"summary": "Read-only kubectl observed hwlab-dev pods with pulled registry images.",
"evidence": {
"kubectlAvailable": true,
"context": {
"command": "kubectl config current-context",
"ok": true,
"exitCode": 0,
"stdout": "in-cluster",
"stderr": "",
"error": null
},
"canGetPods": {
"command": "kubectl auth can-i get pods -n hwlab-dev",
"ok": true,
"exitCode": 0,
"stdout": "yes",
"stderr": "",
"error": null
},
"podCount": 13,
"pulledImages": [
{
"pod": "hwlab-agent-mgr-75544c7747-5dzt9",
"container": "hwlab-agent-mgr",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-agent-mgr@sha256:c16c733306a28ae9385087653b79f5bb2f28724d917a89bb296646f6a02c5318"
},
{
"pod": "hwlab-agent-skills-849d9976f4-2nsmw",
"container": "hwlab-agent-skills",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-agent-skills@sha256:246fb7bb48597a7179aab62cb091e8213e47b71b6871ba94e6028032992e916c"
},
{
"pod": "hwlab-box-simu-55688cbcc7-pftxc",
"container": "hwlab-box-simu",
"image": "127.0.0.1:5000/hwlab/hwlab-box-simu:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-box-simu@sha256:ae5fcc06a276918561c2d752f2221156bd2bd4db7fb93967ae76af97bfdc51bf"
},
{
"pod": "hwlab-box-simu-55688cbcc7-sq5fx",
"container": "hwlab-box-simu",
"image": "127.0.0.1:5000/hwlab/hwlab-box-simu:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-box-simu@sha256:ae5fcc06a276918561c2d752f2221156bd2bd4db7fb93967ae76af97bfdc51bf"
},
{
"pod": "hwlab-cloud-api-f48dcdfb-9n6zh",
"container": "hwlab-cloud-api",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-cloud-api@sha256:3946efdd7360409cea6a897cb03a6f3172f14819d3132df3aab821621909513f"
},
{
"pod": "hwlab-cloud-web-cd9655b98-gxtmc",
"container": "hwlab-cloud-web",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-cloud-web@sha256:868e2a11c56cc1a27c77de3fa71a7b092f21ae98a4052104e9071bd93960abfa"
},
{
"pod": "hwlab-edge-proxy-567f978cdd-8fwc8",
"container": "hwlab-edge-proxy",
"image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-edge-proxy@sha256:384832fc579063b9b0a940973690734bb402607720fd36f4662e4ee6fa5a57d2"
},
{
"pod": "hwlab-frpc-59f57d479c-wm5l2",
"container": "frpc",
"image": "127.0.0.1:5000/hwlab/frpc:v0.68.1",
"imageID": "127.0.0.1:5000/hwlab/frpc@sha256:ce400cda42a260964e40ab1ca68b868f72c569cdce4b243aef36ce40194d287b"
},
{
"pod": "hwlab-gateway-simu-699fd486b-j9pk7",
"container": "hwlab-gateway-simu",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-gateway-simu@sha256:00599721b910b5667b6be43c416cab4c410c6cc29b7f56ecdfb6eff71123d01c"
},
{
"pod": "hwlab-gateway-simu-699fd486b-phcgp",
"container": "hwlab-gateway-simu",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-gateway-simu@sha256:00599721b910b5667b6be43c416cab4c410c6cc29b7f56ecdfb6eff71123d01c"
},
{
"pod": "hwlab-patch-panel-757f9f44d5-7np8r",
"container": "hwlab-patch-panel",
"image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-patch-panel@sha256:5b3aa6505492c2ed398d21cbd35a8754672114acd0fa97faa3351d4b6a6dfd5a"
},
{
"pod": "hwlab-router-5d9cbdf89b-p7r94",
"container": "hwlab-router",
"image": "127.0.0.1:5000/hwlab/hwlab-router:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-router@sha256:217b1fdb0f704c4183e9d8503644177f0c8bae674da0433985c19ef909f0e342"
},
{
"pod": "hwlab-tunnel-client-65c7858c8-wjqsh",
"container": "hwlab-tunnel-client",
"image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:cb35ada",
"imageID": "127.0.0.1:5000/hwlab/hwlab-tunnel-client@sha256:9bcdd8a58132d7bc4dbfc71297f802fc0ab407a1b5c1bb1ea58e0791147a243b"
}
],
"pullFailures": [],
"pullAttempted": false,
"mutationAttempted": false
}
}
},
"interpretation": {
"processHttpAccess": "Runner process HTTP access to /v2/ is diagnostic only and must not be treated as Docker daemon push access.",
"dockerDaemonPushAccess": "Docker daemon push access is the publish-path capability. This probe is read-only and does not push.",
"k3sPullAccess": "k3s pull access is the deploy-path capability. This probe is read-only and does not create pods or read secrets."
}
}
},
"notes": "DEV-only artifact report. Do not treat runtime placeholder images as real service implementations."
}
File diff suppressed because it is too large Load Diff
+39 -39
View File
@@ -16,6 +16,7 @@ import {
serviceInventoryFromServices
} from "./src/dev-artifact-services.mjs";
import { runDevBaseImagePreflight } from "./src/dev-base-image-preflight.mjs";
import { probeRegistryCapabilities } from "./src/registry-capabilities.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultRegistryPrefix =
@@ -152,6 +153,17 @@ function summarizeBaseImagePreflight(result) {
};
}
function summarizeRegistryCapabilities(result) {
return {
version: result.version,
registryPrefix: result.registryPrefix,
generatedAt: result.generatedAt,
classification: result.classification,
dimensions: result.dimensions,
interpretation: result.interpretation
};
}
function baseImagePreflightBlocker(result) {
return blocker({
type: "environment_blocker",
@@ -454,7 +466,7 @@ function sourceStateBlocker(service) {
});
}
async function preflight({ args, services, catalog, deployManifest, baseImagePreflight }) {
async function preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities }) {
const blockers = [];
let registryPrefix = args.registryPrefix;
@@ -504,20 +516,15 @@ async function preflight({ args, services, catalog, deployManifest, baseImagePre
}
}
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)) {
blockers.push(
blocker({
type: "network_blocker",
scope: "registry",
summary: "No Docker-visible registry container is publishing port 5000.",
next: "Start or expose the D601 local registry before running --publish."
})
);
}
}
if (registryCapabilities?.dimensions?.dockerDaemonPushAccess?.status === "blocked") {
blockers.push(
blocker({
type: "network_blocker",
scope: "docker-daemon-push-access",
summary: registryCapabilities.dimensions.dockerDaemonPushAccess.summary,
next: "Restore Docker daemon access to the D601 local/internal registry before running --publish."
})
);
}
for (const service of services) {
@@ -561,22 +568,6 @@ function hasFatalBlocker(blockers) {
return blockers.some((item) => fatalBlockerTypes.has(item.type));
}
function dockerPsShowsRegistry5000(stdout) {
return stdout
.split("\n")
.filter(Boolean)
.some((line) => {
try {
const row = JSON.parse(line);
const image = String(row.Image ?? "");
const ports = String(row.Ports ?? "");
return image.startsWith("registry:") && ports.includes("5000->5000/tcp");
} catch {
return line.includes("registry:") && /5000(?:->|\\u003e)5000\/tcp/u.test(line);
}
});
}
async function buildService({ args, repo, commitId, shortCommit, service }) {
const tag = shortCommit;
const ref = imageRef(args.registryPrefix, service.serviceId, tag);
@@ -783,13 +774,14 @@ function artifactRecord({ args, service, shortCommit, status, digest = "not_publ
};
}
function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked }) {
function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked, registryCapabilities }) {
return {
version: "v2",
mode,
sourceCommitId: commitId,
imageTag: shortCommit,
registryTarget: args.registryPrefix,
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
digestPlaceholder: "not_published",
services: artifacts.map((artifact) => {
const image = artifact.image ?? imageRef(args.registryPrefix, artifact.serviceId, shortCommit);
@@ -813,12 +805,12 @@ function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlo
};
}
function createReport({ args, repo, commitId, shortCommit, mode, services, artifacts, blockers, baseImagePreflight }) {
function createReport({ args, repo, commitId, shortCommit, mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities }) {
const status = reportStatus(mode, artifacts, blockers);
const fatalBlocked = hasFatalBlocker(blockers);
const requiredArtifacts = artifacts.filter((artifact) => artifact.artifactRequired);
const serviceInventory = serviceInventoryFromServices(services);
const publishPlan = publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked });
const publishPlan = publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked, registryCapabilities });
const publishedCount = requiredArtifacts.filter((artifact) => artifact.status === "published").length;
const builtCount = requiredArtifacts.filter((artifact) => ["built", "published", "published_unverified_digest"].includes(artifact.status)).length;
@@ -846,6 +838,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
validationCommands: [
"node --check scripts/dev-artifact-publish.mjs",
"node --check scripts/src/dev-artifact-services.mjs",
"node --check scripts/src/registry-capabilities.mjs",
"node --check scripts/preflight-dev-base-image.mjs",
"node --check scripts/src/dev-base-image-preflight.mjs",
"node scripts/preflight-dev-base-image.mjs",
@@ -894,6 +887,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
repo,
sourceCommitId: commitId,
registryPrefix: args.registryPrefix,
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
baseImage: args.baseImage ?? null,
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
environment: ENVIRONMENT_DEV,
@@ -942,9 +936,14 @@ async function main() {
}
args.registryPrefix = validateRegistryPrefix(args.registryPrefix);
const baseImagePreflight = await runDevBaseImagePreflight({
env: preflightEnvForBaseImage(args.baseImage)
});
const [baseImagePreflight, registryCapabilities] = await Promise.all([
runDevBaseImagePreflight({
env: preflightEnvForBaseImage(args.baseImage)
}),
probeRegistryCapabilities({
registryPrefix: args.registryPrefix
})
]);
if (baseImagePreflight.publishUsable) {
args.baseImage = baseImagePreflight.localTag;
}
@@ -958,7 +957,7 @@ async function main() {
const repo = repoLabelFromRemote(remoteUrl);
const services = await resolveServices(args.services, catalog);
let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight });
let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities });
let artifacts = services.map((service) => artifactRecord({
args,
service,
@@ -995,7 +994,7 @@ async function main() {
artifacts = services.map((service) => artifactByServiceId.get(service.serviceId));
}
const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, services, artifacts, blockers, baseImagePreflight });
const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities });
if (args.emitReport) {
await writeReport(args.reportPath, report);
}
@@ -1006,6 +1005,7 @@ async function main() {
reportPath: args.emitReport ? args.reportPath : null,
sourceCommitId: commitId,
registryPrefix: args.registryPrefix,
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
publishPlan: report.artifactPublish.publishPlan,
services: report.artifactPublish.services.map((service) => ({
+78 -11
View File
@@ -11,12 +11,13 @@ import {
summarizeDbContract
} from "../../internal/cloud/db-contract.mjs";
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs";
import { probeRegistryCapabilities } from "./registry-capabilities.mjs";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const defaultReportPath = "reports/dev-gate/dev-preflight-report.json";
const issue = "pikasTech/HWLAB#34";
const supports = ["#7", "#12", "#14", "#22", "#23", "#29", "#30", "#31", "#33", "#39", "#49"].map(
const supports = ["#7", "#12", "#14", "#22", "#23", "#29", "#30", "#31", "#33", "#39", "#49", "#66"].map(
(id) => `pikasTech/HWLAB${id}`
).concat(["pikasTech/HWLAB#33", "pikasTech/HWLAB#35", "pikasTech/HWLAB#48"]);
const forbiddenActions = [
@@ -540,6 +541,54 @@ function validateArtifactPublishReport(reporter, artifactReport, targetShortComm
});
}
function validateRegistryCapabilities(reporter, registryCapabilities) {
for (const dimension of Object.values(registryCapabilities.dimensions)) {
reporter.check(
`registry-capability-${dimension.id}`,
"registry",
dimension.status,
dimension.summary,
[dimension]
);
}
reporter.check(
"registry-capability-classification",
"registry",
registryCapabilities.classification,
`Registry capability classification is ${registryCapabilities.classification}: process HTTP, Docker daemon push path, and k3s pull path are reported separately.`,
[registryCapabilities]
);
const dockerDaemonPushAccess = registryCapabilities.dimensions.dockerDaemonPushAccess;
if (dockerDaemonPushAccess.status === "blocked") {
reporter.block({
type: "network_blocker",
scope: "docker-daemon-push-access",
summary: dockerDaemonPushAccess.summary,
nextTask: "Restore Docker daemon access to the D601 local/internal registry before claiming DEV artifact publish readiness."
});
}
const k3sPullAccess = registryCapabilities.dimensions.k3sPullAccess;
if (k3sPullAccess.status === "blocked") {
reporter.block({
type: "network_blocker",
scope: "k3s-pull-access",
summary: k3sPullAccess.summary,
nextTask: "Repair read-only hwlab-dev k3s access or image pull configuration before claiming deploy-path registry readiness."
});
}
}
function manifestReadStatus(registryEvidence) {
if (registryEvidence.every((probe) => probe.ok)) return "pass";
if (registryEvidence.some((probe) => probe.error || probe.status === 401 || probe.status === 403 || probe.status === 404)) {
return "degraded";
}
return "blocked";
}
function validateEdgeHealthReport(reporter, edgeReport) {
if (!edgeReport) {
reporter.check("dev-edge-health-report", "edge", "blocked", "No DEV edge health report is present.");
@@ -806,18 +855,20 @@ async function validateLiveProbes(reporter, catalog, timeoutMs) {
});
return { serviceId: service.serviceId, image: service.image, ...probe };
}));
const registryOk = registryEvidence.every((probe) => probe.ok);
const registryStatus = manifestReadStatus(registryEvidence);
reporter.check(
"registry-manifest-read",
"registry",
registryOk ? "pass" : "blocked",
registryOk ? "All required catalog images were visible through registry manifest HEAD probes." : "One or more required catalog images could not be verified through registry manifest HEAD probes.",
"catalog-image-manifest-read",
"artifact-catalog",
registryStatus,
registryStatus === "pass"
? "All required catalog images were visible through manifest HEAD probes."
: "One or more required catalog images could not be verified through manifest HEAD probes; this is runner-process catalog evidence, not Docker daemon push access.",
registryEvidence
);
if (!registryOk) {
if (registryStatus === "blocked") {
reporter.block({
type: "runtime_blocker",
scope: "registry-manifest-read",
scope: "catalog-image-manifest-read",
summary: "This preflight could not verify registry manifests for the required DEV catalog images without reading credentials.",
nextTask: "Publish required DEV images or provide a non-secret registry evidence artifact with immutable digests for each required HWLAB service."
});
@@ -848,9 +899,10 @@ async function writeReport(report, reportPath) {
await writeFile(absoluteReportPath, `${JSON.stringify(report, null, 2)}\n`);
}
function makeReport(args, targetCommit, targetShortCommit, reporter, artifactIdentity) {
function makeReport(args, targetCommit, targetShortCommit, reporter, artifactIdentity, registryCapabilities) {
const blockingCheckStatuses = new Set(["blocked", "failed"]);
const conclusion = reporter.blockers.length === 0 &&
reporter.checks.every((item) => item.status === "pass") ? "ready" : "blocked";
reporter.checks.every((item) => !blockingCheckStatuses.has(item.status)) ? "ready" : "blocked";
return {
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-preflight-report.schema.json",
@@ -872,12 +924,14 @@ function makeReport(args, targetCommit, targetShortCommit, reporter, artifactIde
validationCommands: [
"node --check scripts/dev-gate-preflight.mjs",
"node --check scripts/src/dev-gate-preflight.mjs",
"node --check scripts/src/registry-capabilities.mjs",
"node --check scripts/refresh-artifact-catalog.mjs",
"node scripts/dev-gate-preflight.mjs",
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs"
],
artifactIdentity,
registryCapabilities,
conclusion,
checks: reporter.checks,
blockers: reporter.blockers,
@@ -893,6 +947,13 @@ function printSummary(args, report) {
targetShortCommit: report.target.shortCommitId,
artifactCatalogCommit: report.artifactIdentity?.artifactCatalog?.commitId,
catalogDigests: report.artifactIdentity?.artifactCatalog?.digestCounts,
registryCapabilities: {
classification: report.registryCapabilities?.classification,
dimensions: Object.fromEntries(Object.entries(report.registryCapabilities?.dimensions ?? {}).map(([key, value]) => [
key,
value.status
]))
},
conclusion: report.conclusion,
report: args.writeReport ? args.reportPath : null,
checks: report.checks.reduce((counts, item) => {
@@ -921,6 +982,11 @@ export async function runPreflight(argv) {
const reporter = makeReporter();
const contracts = await loadContracts();
const optionalReports = await loadOptionalReports();
const registryPrefix = optionalReports.artifactPublish?.artifactPublish?.registryPrefix ?? "127.0.0.1:5000/hwlab";
const registryCapabilities = await probeRegistryCapabilities({
registryPrefix,
timeoutMs: args.timeoutMs
});
const { deploy, catalog, masterEdge, artifactIdentity } = validateLocalContracts(
reporter,
contracts,
@@ -929,6 +995,7 @@ export async function runPreflight(argv) {
args.targetRef
);
validateRegistryCapabilities(reporter, registryCapabilities);
validateCloudApiDbContract(reporter, deploy, contracts[3]);
validateArtifactPublishReport(reporter, optionalReports.artifactPublish, targetShortCommit, targetCommit, args.targetRef);
validateEdgeHealthReport(reporter, optionalReports.edgeHealth);
@@ -936,7 +1003,7 @@ export async function runPreflight(argv) {
validateRuntimeBoundary(reporter, deploy);
await validateLiveProbes(reporter, catalog, args.timeoutMs);
const report = makeReport(args, targetCommit, targetShortCommit, reporter, artifactIdentity);
const report = makeReport(args, targetCommit, targetShortCommit, reporter, artifactIdentity, registryCapabilities);
if (args.writeReport) {
await writeReport(report, args.reportPath);
}
+389
View File
@@ -0,0 +1,389 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const defaultRegistryPrefix = "127.0.0.1:5000/hwlab";
const imagePullBackoffReasons = new Set(["ErrImagePull", "ImagePullBackOff", "InvalidImageName"]);
function commandLine(command, args) {
return [command, ...args].join(" ");
}
async function run(command, args = [], { timeoutMs = 5000 } = {}) {
const commandText = commandLine(command, args);
try {
const result = await execFileAsync(command, args, {
timeout: timeoutMs,
maxBuffer: 1024 * 1024
});
return {
ok: true,
command: commandText,
exitCode: 0,
stdout: result.stdout.trim(),
stderr: result.stderr.trim()
};
} catch (error) {
return {
ok: false,
command: commandText,
exitCode: typeof error.code === "number" ? error.code : 1,
stdout: String(error.stdout ?? "").trim(),
stderr: String(error.stderr ?? "").trim(),
error: error.message
};
}
}
async function commandExists(command, timeoutMs) {
return (await run("which", [command], { timeoutMs })).ok;
}
function fetchErrorMessage(error) {
if (!(error instanceof Error)) {
return String(error);
}
const cause = error.cause;
if (cause && typeof cause === "object") {
const code = "code" in cause ? String(cause.code) : "";
const address = "address" in cause ? String(cause.address) : "";
const port = "port" in cause ? String(cause.port) : "";
return [error.message, code, address, port].filter(Boolean).join(" ");
}
return error.message;
}
async function httpProbe(url, { timeoutMs = 5000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: "GET",
signal: controller.signal
});
const body = await response.text();
return {
ok: response.ok,
url,
method: "GET",
status: response.status,
statusText: response.statusText,
body: body.slice(0, 500)
};
} catch (error) {
return {
ok: false,
url,
method: "GET",
error: fetchErrorMessage(error)
};
} finally {
clearTimeout(timer);
}
}
export function parseRegistryPrefix(prefix = defaultRegistryPrefix) {
const normalized = prefix.replace(/\/+$/u, "");
const [hostPort, ...pathParts] = normalized.split("/");
const host = hostPort.split(":")[0].toLowerCase();
const port = hostPort.includes(":") ? hostPort.split(":").at(-1) : null;
return {
normalized,
hostPort,
host,
port,
namespace: pathParts.join("/")
};
}
export function registryApiEndpoint(prefix = defaultRegistryPrefix) {
const parsed = parseRegistryPrefix(prefix);
return `http://${parsed.hostPort}/v2/`;
}
function isLoopbackRegistry5000(prefix) {
const parsed = parseRegistryPrefix(prefix);
return (parsed.host === "127.0.0.1" || parsed.host === "localhost") && parsed.port === "5000";
}
function summarizeCommand(result) {
return {
command: result.command,
ok: result.ok,
exitCode: result.exitCode,
stdout: result.stdout.slice(0, 1000),
stderr: result.stderr.slice(0, 1000),
error: result.error ?? null
};
}
function parseDockerPs(stdout) {
return stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
}
function registryContainersFromDockerPs(stdout) {
return parseDockerPs(stdout)
.filter((row) => {
const image = String(row.Image ?? "");
const ports = String(row.Ports ?? "");
return image.startsWith("registry:") && ports.includes("5000->5000/tcp");
})
.map((row) => ({
id: row.ID ?? null,
image: row.Image ?? null,
names: row.Names ?? null,
ports: row.Ports ?? null,
state: row.State ?? null,
status: row.Status ?? null
}));
}
async function probeProcessHttpAccess(registryPrefix, timeoutMs) {
const endpoint = registryApiEndpoint(registryPrefix);
const probe = await httpProbe(endpoint, { timeoutMs });
const passed = probe.ok || probe.status === 401;
return {
id: "process-http-access",
status: passed ? "pass" : "degraded",
requiredForPublish: false,
requiredForDeploy: false,
endpoint,
probeKind: "runner-process-http-v2",
summary: passed
? `Runner process can reach ${endpoint}.`
: `Runner process cannot reach ${endpoint}; this is process HTTP access only and does not prove Docker daemon push failure.`,
evidence: {
probe,
publishFailure: false
}
};
}
async function probeDockerDaemonPushAccess(registryPrefix, timeoutMs) {
const dockerVersion = await run("docker", ["--version"], { timeoutMs });
if (!dockerVersion.ok) {
return {
id: "docker-daemon-push-access",
status: "blocked",
requiredForPublish: true,
requiredForDeploy: false,
endpoint: registryPrefix,
probeKind: "docker-cli-read-only",
summary: "Docker CLI is not available, so Docker daemon push access cannot be assessed.",
evidence: {
dockerVersion: summarizeCommand(dockerVersion),
pushAttempted: false,
mutationAttempted: false
}
};
}
const dockerPs = await run("docker", ["ps", "--format", "{{json .}}"], { timeoutMs });
if (!dockerPs.ok) {
return {
id: "docker-daemon-push-access",
status: "blocked",
requiredForPublish: true,
requiredForDeploy: false,
endpoint: registryPrefix,
probeKind: "docker-ps-read-only",
summary: "Docker daemon is not readable, so Docker daemon push access cannot be assessed.",
evidence: {
dockerVersion: summarizeCommand(dockerVersion),
dockerPs: summarizeCommand(dockerPs),
pushAttempted: false,
mutationAttempted: false
}
};
}
const matchingContainers = registryContainersFromDockerPs(dockerPs.stdout);
const registryVisible = !isLoopbackRegistry5000(registryPrefix) || matchingContainers.length > 0;
return {
id: "docker-daemon-push-access",
status: registryVisible ? "pass" : "blocked",
requiredForPublish: true,
requiredForDeploy: false,
endpoint: registryPrefix,
probeKind: "docker-ps-read-only",
summary: registryVisible
? "Docker daemon can see a local/internal registry target for publish preflight purposes; no push was attempted."
: "Docker daemon did not show a registry container publishing port 5000.",
evidence: {
dockerVersion: summarizeCommand(dockerVersion),
registryPrefix,
loopbackPort5000: isLoopbackRegistry5000(registryPrefix),
registryContainerVisible: registryVisible,
matchingContainers,
pushAttempted: false,
mutationAttempted: false
}
};
}
function parsePodList(stdout) {
try {
const parsed = JSON.parse(stdout);
return Array.isArray(parsed.items) ? parsed.items : [];
} catch {
return [];
}
}
function podPullEvidence(pods, registryPrefix) {
const parsed = parseRegistryPrefix(registryPrefix);
const hostNeedle = `${parsed.hostPort}/`;
const pulledImages = [];
const pullFailures = [];
for (const pod of pods) {
const podName = pod?.metadata?.name ?? "unknown";
for (const status of pod?.status?.containerStatuses ?? []) {
const image = String(status.image ?? "");
const imageID = String(status.imageID ?? "");
const waitingReason = status.state?.waiting?.reason;
if (waitingReason && imagePullBackoffReasons.has(waitingReason)) {
pullFailures.push({
pod: podName,
container: status.name ?? null,
image,
reason: waitingReason
});
}
if (image.includes(hostNeedle) && imageID.includes("sha256:")) {
pulledImages.push({
pod: podName,
container: status.name ?? null,
image,
imageID
});
}
}
}
return {
pulledImages,
pullFailures
};
}
async function probeK3sPullAccess(registryPrefix, timeoutMs) {
const kubectlExists = await commandExists("kubectl", timeoutMs);
if (!kubectlExists) {
return {
id: "k3s-pull-access",
status: "blocked",
requiredForPublish: false,
requiredForDeploy: true,
endpoint: registryPrefix,
probeKind: "kubectl-read-only",
summary: "kubectl is not installed, so k3s pull access cannot be assessed from this runner.",
evidence: {
kubectlAvailable: false,
pullAttempted: false,
mutationAttempted: false
}
};
}
const [context, canGetPods, podsResult] = await Promise.all([
run("kubectl", ["config", "current-context"], { timeoutMs }),
run("kubectl", ["auth", "can-i", "get", "pods", "-n", "hwlab-dev"], { timeoutMs }),
run("kubectl", ["-n", "hwlab-dev", "get", "pods", "-o", "json"], { timeoutMs })
]);
const canReadPods = canGetPods.ok && canGetPods.stdout === "yes" && podsResult.ok;
if (!canReadPods) {
return {
id: "k3s-pull-access",
status: "blocked",
requiredForPublish: false,
requiredForDeploy: true,
endpoint: registryPrefix,
probeKind: "kubectl-read-only",
summary: "Read-only kubectl probes could not inspect hwlab-dev pods, so k3s pull access is blocked.",
evidence: {
kubectlAvailable: true,
context: summarizeCommand(context),
canGetPods: summarizeCommand(canGetPods),
pods: summarizeCommand(podsResult),
pullAttempted: false,
mutationAttempted: false
}
};
}
const pods = parsePodList(podsResult.stdout);
const evidence = podPullEvidence(pods, registryPrefix);
const hasFailures = evidence.pullFailures.length > 0;
const hasPulledImages = evidence.pulledImages.length > 0;
return {
id: "k3s-pull-access",
status: hasFailures ? "blocked" : (hasPulledImages ? "pass" : "degraded"),
requiredForPublish: false,
requiredForDeploy: true,
endpoint: registryPrefix,
probeKind: "kubectl-read-only",
summary: hasFailures
? "Read-only kubectl observed image pull failures in hwlab-dev."
: (hasPulledImages
? "Read-only kubectl observed hwlab-dev pods with pulled registry images."
: "Read-only kubectl can inspect hwlab-dev, but no current pod proves k3s pulled this registry prefix."),
evidence: {
kubectlAvailable: true,
context: summarizeCommand(context),
canGetPods: summarizeCommand(canGetPods),
podCount: pods.length,
pulledImages: evidence.pulledImages,
pullFailures: evidence.pullFailures,
pullAttempted: false,
mutationAttempted: false
}
};
}
function capabilityClassification(dimensions) {
const values = Object.values(dimensions).map((dimension) => dimension.status);
if (values.includes("blocked")) return "blocked";
if (values.includes("degraded")) return "degraded";
return "pass";
}
export async function probeRegistryCapabilities({
registryPrefix = defaultRegistryPrefix,
timeoutMs = 5000
} = {}) {
const [processHttpAccess, dockerDaemonPushAccess, k3sPullAccess] = await Promise.all([
probeProcessHttpAccess(registryPrefix, timeoutMs),
probeDockerDaemonPushAccess(registryPrefix, timeoutMs),
probeK3sPullAccess(registryPrefix, timeoutMs)
]);
const dimensions = {
processHttpAccess,
dockerDaemonPushAccess,
k3sPullAccess
};
return {
version: "v1",
registryPrefix,
generatedAt: new Date().toISOString(),
classification: capabilityClassification(dimensions),
dimensions,
interpretation: {
processHttpAccess: "Runner process HTTP access to /v2/ is diagnostic only and must not be treated as Docker daemon push access.",
dockerDaemonPushAccess: "Docker daemon push access is the publish-path capability. This probe is read-only and does not push.",
k3sPullAccess: "k3s pull access is the deploy-path capability. This probe is read-only and does not create pods or read secrets."
}
};
}
+79 -2
View File
@@ -58,6 +58,7 @@ const requiredDevDeployApplyValidationCommands = [
const requiredPreflightValidationCommands = [
"node --check scripts/dev-gate-preflight.mjs",
"node --check scripts/src/dev-gate-preflight.mjs",
"node --check scripts/src/registry-capabilities.mjs",
"node --check scripts/refresh-artifact-catalog.mjs",
"node scripts/dev-gate-preflight.mjs"
];
@@ -166,6 +167,8 @@ const reportFamilyTemplates = new Map([
],
requiredValidationCommands: [
"node --check scripts/dev-artifact-publish.mjs",
"node --check scripts/src/dev-artifact-services.mjs",
"node --check scripts/src/registry-capabilities.mjs",
"node --check scripts/preflight-dev-base-image.mjs",
"node --check scripts/src/dev-base-image-preflight.mjs",
"node scripts/preflight-dev-base-image.mjs",
@@ -195,7 +198,7 @@ const reportFamilyTemplates = new Map([
]
]);
const statusValues = new Set(["pass", "blocked", "not_run", "not_applicable", "failed"]);
const statusValues = new Set(["pass", "blocked", "degraded", "not_run", "not_applicable", "failed"]);
const blockerTypes = new Set([
"contract_blocker",
"environment_blocker",
@@ -208,6 +211,16 @@ const blockerTypes = new Set([
const blockerStates = new Set(["open", "acknowledged", "closed"]);
const artifactSourceStates = new Set(["source-present", "intentionally-disabled"]);
const preflightConclusions = new Set(["ready", "blocked"]);
const registryCapabilityDimensionIds = new Set([
"process-http-access",
"docker-daemon-push-access",
"k3s-pull-access"
]);
const registryCapabilityDimensionKeys = new Set([
"processHttpAccess",
"dockerDaemonPushAccess",
"k3sPullAccess"
]);
const requiredPreflightSupports = [
"pikasTech/HWLAB#7",
"pikasTech/HWLAB#12",
@@ -215,7 +228,8 @@ const requiredPreflightSupports = [
"pikasTech/HWLAB#23",
"pikasTech/HWLAB#29",
"pikasTech/HWLAB#30",
"pikasTech/HWLAB#31"
"pikasTech/HWLAB#31",
"pikasTech/HWLAB#66"
];
function assertObject(value, label) {
@@ -461,6 +475,8 @@ async function validateReport(relativePath) {
if (Object.hasOwn(report, "artifactPublish")) {
assertObject(report.artifactPublish, `${label}.artifactPublish`);
assertRegistryCapabilities(report.artifactPublish.registryCapabilities, `${label}.artifactPublish.registryCapabilities`);
assertRegistryCapabilities(report.artifactPublish.publishPlan?.registryCapabilities, `${label}.artifactPublish.publishPlan.registryCapabilities`);
assertArray(report.artifactPublish.services ?? [], `${label}.artifactPublish.services`);
for (const [index, service] of report.artifactPublish.services.entries()) {
const serviceLabel = `${label}.artifactPublish.services[${index}]`;
@@ -709,6 +725,65 @@ function assertShaOrNotPublished(value, label) {
);
}
function assertRegistryCapabilityDimension(dimension, label, expectedId) {
assertObject(dimension, label);
for (const field of [
"id",
"status",
"requiredForPublish",
"requiredForDeploy",
"endpoint",
"probeKind",
"summary",
"evidence"
]) {
assert.ok(Object.hasOwn(dimension, field), `${label} missing ${field}`);
}
assert.equal(dimension.id, expectedId, `${label}.id`);
assert.ok(registryCapabilityDimensionIds.has(dimension.id), `${label}.id must be a known registry capability dimension`);
assertStatus(dimension.status, `${label}.status`);
assert.equal(typeof dimension.requiredForPublish, "boolean", `${label}.requiredForPublish`);
assert.equal(typeof dimension.requiredForDeploy, "boolean", `${label}.requiredForDeploy`);
assertString(dimension.endpoint, `${label}.endpoint`);
assertString(dimension.probeKind, `${label}.probeKind`);
assertString(dimension.summary, `${label}.summary`);
assertObject(dimension.evidence, `${label}.evidence`);
}
function assertRegistryCapabilities(capabilities, label) {
assertObject(capabilities, label);
for (const field of [
"version",
"registryPrefix",
"generatedAt",
"classification",
"dimensions",
"interpretation"
]) {
assert.ok(Object.hasOwn(capabilities, field), `${label} missing ${field}`);
}
assert.equal(capabilities.version, "v1", `${label}.version`);
assertString(capabilities.registryPrefix, `${label}.registryPrefix`);
assertTimestamp(capabilities.generatedAt, `${label}.generatedAt`);
assertStatus(capabilities.classification, `${label}.classification`);
assertObject(capabilities.dimensions, `${label}.dimensions`);
assert.deepEqual(
new Set(Object.keys(capabilities.dimensions)),
registryCapabilityDimensionKeys,
`${label}.dimensions must contain processHttpAccess, dockerDaemonPushAccess, and k3sPullAccess`
);
assertRegistryCapabilityDimension(capabilities.dimensions.processHttpAccess, `${label}.dimensions.processHttpAccess`, "process-http-access");
assertRegistryCapabilityDimension(capabilities.dimensions.dockerDaemonPushAccess, `${label}.dimensions.dockerDaemonPushAccess`, "docker-daemon-push-access");
assertRegistryCapabilityDimension(capabilities.dimensions.k3sPullAccess, `${label}.dimensions.k3sPullAccess`, "k3s-pull-access");
assert.equal(capabilities.dimensions.processHttpAccess.requiredForPublish, false, `${label}.dimensions.processHttpAccess.requiredForPublish`);
assert.equal(capabilities.dimensions.dockerDaemonPushAccess.requiredForPublish, true, `${label}.dimensions.dockerDaemonPushAccess.requiredForPublish`);
assert.equal(capabilities.dimensions.k3sPullAccess.requiredForDeploy, true, `${label}.dimensions.k3sPullAccess.requiredForDeploy`);
assertObject(capabilities.interpretation, `${label}.interpretation`);
for (const key of registryCapabilityDimensionKeys) {
assertString(capabilities.interpretation[key], `${label}.interpretation.${key}`);
}
}
function assertArtifactIdentity(identity, label, target) {
assertObject(identity, label);
for (const field of [
@@ -1062,6 +1137,7 @@ async function validatePreflightReport(relativePath, report) {
"forbiddenActions",
"validationCommands",
"artifactIdentity",
"registryCapabilities",
"conclusion",
"checks",
"blockers"
@@ -1104,6 +1180,7 @@ async function validatePreflightReport(relativePath, report) {
}
assertArtifactIdentity(report.artifactIdentity, `${label}.artifactIdentity`, report.target);
assertRegistryCapabilities(report.registryCapabilities, `${label}.registryCapabilities`);
assertArray(report.checks, `${label}.checks`);
assert.ok(report.checks.length >= 1, `${label}.checks must not be empty`);