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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user