diff --git a/cmd/hwlab-edge-proxy/main.mjs b/cmd/hwlab-edge-proxy/main.mjs new file mode 100644 index 00000000..4362d313 --- /dev/null +++ b/cmd/hwlab-edge-proxy/main.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import { + healthPayload, + listen, + proxyHttpRequest, + resolveHostPort, + sendJson +} from "../../internal/dev-entrypoint/http.mjs"; +import { createServer } from "node:http"; + +const serviceId = "hwlab-edge-proxy"; +const upstream = process.env.HWLAB_EDGE_UPSTREAM || "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667"; +const { host, port } = resolveHostPort({ + listenEnv: "HWLAB_EDGE_LISTEN", + hostEnv: "HWLAB_EDGE_HOST", + portEnv: "HWLAB_EDGE_PORT", + fallbackPort: 6667 +}); + +function proxyDetails() { + return { + upstream, + mode: "dev-edge-proxy" + }; +} + +const server = createServer((request, response) => { + const url = new URL(request.url || "/", "http://hwlab-edge-proxy.local"); + + if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) { + sendJson(response, 200, healthPayload({ + serviceId, + role: "public-dev-ingress", + details: proxyDetails() + })); + return; + } + + if (request.method === "GET" && (url.pathname === "/status" || url.pathname === "/routes")) { + sendJson(response, 200, { + serviceId, + environment: "dev", + status: "ok", + upstream, + routes: [ + { + pathPrefix: "/", + upstream, + mode: "http-proxy" + } + ] + }); + return; + } + + if (request.method === "GET" && url.pathname === "/help") { + sendJson(response, 200, { + serviceId, + commands: ["GET /health", "GET /health/live", "GET /status", "GET /routes"] + }); + return; + } + + proxyHttpRequest({ request, response, upstream }); +}); + +listen(server, { serviceId, host, port }); diff --git a/cmd/hwlab-router/main.mjs b/cmd/hwlab-router/main.mjs new file mode 100644 index 00000000..436750dc --- /dev/null +++ b/cmd/hwlab-router/main.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import { + createServiceServer, + listen, + resolveHostPort +} from "../../internal/dev-entrypoint/http.mjs"; + +const serviceId = "hwlab-router"; +const mode = process.env.HWLAB_ROUTER_MODE || "edge-reachability"; +const edgeProxyService = process.env.HWLAB_EDGE_PROXY_SERVICE || "hwlab-edge-proxy"; +const cloudApiService = process.env.HWLAB_CLOUD_API_SERVICE || "hwlab-cloud-api"; +const { host, port } = resolveHostPort({ + listenEnv: "HWLAB_ROUTER_LISTEN", + hostEnv: "HWLAB_ROUTER_HOST", + portEnv: "HWLAB_ROUTER_PORT", + fallbackPort: 7401 +}); + +const routeTable = [ + { + pathPrefix: "/health", + targetServiceId: cloudApiService, + mode: "local-health" + }, + { + pathPrefix: "/", + targetServiceId: cloudApiService, + via: edgeProxyService, + mode: "dev-edge-reachability" + } +]; + +const routes = new Map(); + +routes.set("GET /status", () => ({ + serviceId, + environment: "dev", + status: "ok", + mode, + edgeProxyService, + cloudApiService, + routeTable +})); + +routes.set("GET /routes", () => ({ + serviceId, + routeTable +})); + +listen( + createServiceServer({ + serviceId, + role: "dev-router", + routes, + details: () => ({ + mode, + edgeProxyService, + cloudApiService, + routeCount: routeTable.length + }) + }), + { serviceId, host, port } +); diff --git a/cmd/hwlab-tunnel-client/main.mjs b/cmd/hwlab-tunnel-client/main.mjs new file mode 100644 index 00000000..2608387d --- /dev/null +++ b/cmd/hwlab-tunnel-client/main.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import { + createServiceServer, + listen, + parsePort, + resolveHostPort +} from "../../internal/dev-entrypoint/http.mjs"; + +const serviceId = "hwlab-tunnel-client"; +const mode = process.env.HWLAB_TUNNEL_MODE || "frp-client"; +const serverAddress = process.env.HWLAB_FRP_SERVER_ADDR || "74.48.78.17"; +const publicPort = parsePort(process.env.HWLAB_FRP_PUBLIC_PORT, 6667); +const localService = process.env.HWLAB_TUNNEL_LOCAL_SERVICE || "hwlab-edge-proxy"; +const localPort = parsePort(process.env.HWLAB_TUNNEL_LOCAL_PORT, 6667); +const { host, port } = resolveHostPort({ + listenEnv: "HWLAB_TUNNEL_LISTEN", + hostEnv: "HWLAB_TUNNEL_HOST", + portEnv: "HWLAB_TUNNEL_PORT", + fallbackPort: 7402 +}); + +function tunnelState() { + return { + serviceId, + environment: "dev", + status: "configured", + mode, + reverseLink: { + direction: "d601-to-master", + serverAddress, + publicPort, + localService, + localPort + }, + liveConnection: "not_started_by_entrypoint" + }; +} + +const routes = new Map(); +routes.set("GET /status", () => tunnelState()); +routes.set("GET /tunnel", () => tunnelState().reverseLink); + +listen( + createServiceServer({ + serviceId, + role: "frp-client", + routes, + details: () => tunnelState() + }), + { serviceId, host, port } +); diff --git a/deploy/artifact-catalog.dev.json b/deploy/artifact-catalog.dev.json index dd9e2a5d..dcc9bd7c 100644 --- a/deploy/artifact-catalog.dev.json +++ b/deploy/artifact-catalog.dev.json @@ -73,7 +73,8 @@ "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", - "healthPath": "/health/live" + "healthPath": "/health/live", + "sourceState": "source-present" }, { "serviceId": "hwlab-cloud-web", @@ -84,7 +85,8 @@ "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", - "healthPath": "/health/live" + "healthPath": "/health/live", + "sourceState": "source-present" }, { "serviceId": "hwlab-agent-mgr", @@ -95,7 +97,8 @@ "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", - "healthPath": "/health/live" + "healthPath": "/health/live", + "sourceState": "source-present" }, { "serviceId": "hwlab-agent-worker", @@ -106,7 +109,8 @@ "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", - "healthPath": "/health/live" + "healthPath": "/health/live", + "sourceState": "source-present" }, { "serviceId": "hwlab-gateway", @@ -117,7 +121,8 @@ "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", - "healthPath": "/health/live" + "healthPath": "/health/live", + "sourceState": "source-present" }, { "serviceId": "hwlab-gateway-simu", @@ -128,7 +133,8 @@ "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", - "healthPath": "/health/live" + "healthPath": "/health/live", + "sourceState": "source-present" }, { "serviceId": "hwlab-box-simu", @@ -139,7 +145,8 @@ "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", - "healthPath": "/health/live" + "healthPath": "/health/live", + "sourceState": "source-present" }, { "serviceId": "hwlab-patch-panel", @@ -150,7 +157,8 @@ "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", - "healthPath": "/health/live" + "healthPath": "/health/live", + "sourceState": "source-present" }, { "serviceId": "hwlab-router", @@ -161,7 +169,8 @@ "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", - "healthPath": "/health/live" + "healthPath": "/health/live", + "sourceState": "source-present" }, { "serviceId": "hwlab-tunnel-client", @@ -172,7 +181,8 @@ "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", - "healthPath": "/health/live" + "healthPath": "/health/live", + "sourceState": "source-present" }, { "serviceId": "hwlab-edge-proxy", @@ -183,7 +193,8 @@ "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", - "healthPath": "/health/live" + "healthPath": "/health/live", + "sourceState": "source-present" }, { "serviceId": "hwlab-cli", @@ -194,7 +205,8 @@ "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", - "healthPath": "/health/live" + "healthPath": "/health/live", + "sourceState": "source-present" }, { "serviceId": "hwlab-agent-skills", @@ -205,7 +217,8 @@ "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", - "healthPath": "/health/live" + "healthPath": "/health/live", + "sourceState": "source-present" } ] } diff --git a/docs/artifact-catalog.md b/docs/artifact-catalog.md index 2a884c85..5e547f58 100644 --- a/docs/artifact-catalog.md +++ b/docs/artifact-catalog.md @@ -56,6 +56,7 @@ rules: | `namespace` | Must be `hwlab-dev`. | | `healthPath` | Must be `/health/live` and match the DEV health contract. | | `publishState` | Must be `skeleton-only` in a blocked skeleton catalog, or `published` only when the catalog was refreshed from a successful publish report. | +| `sourceState` | Must be `source-present` for MVP services with repository source, or `intentionally-disabled` for zero-replica services excluded from MVP publication. | | `digest` | Must be `not_published` in the skeleton catalog; it may be a registry `sha256:<64 hex>` digest only after publish evidence exists. | The catalog `commitId` is the artifact source commit, not proof that a registry diff --git a/docs/dev-artifact-publish.md b/docs/dev-artifact-publish.md index f5cbd543..2c74440f 100644 --- a/docs/dev-artifact-publish.md +++ b/docs/dev-artifact-publish.md @@ -77,6 +77,7 @@ Each service record contains: - `digest` - `runtimeKind` - `implementationState` +- `sourceState` - `entrypoint` `digest` is only set to a registry digest after `docker push` succeeds and the @@ -117,5 +118,12 @@ attempted. - `missing-runtime-entrypoint`: the image is only a health placeholder and is not a real service implementation. +## Known Source States + +- `source-present`: the service remains in the MVP artifact catalog and has a + repository-owned source path or entrypoint conclusion. +- `intentionally-disabled`: the service is explicitly out of the MVP artifact + publish set until it is given source. It must not remain a vague blocker. + Runtime implementation blockers do not fake a service implementation. They are recorded as the next minimum work needed after artifact publication. diff --git a/internal/dev-entrypoint/http.mjs b/internal/dev-entrypoint/http.mjs new file mode 100644 index 00000000..48b06614 --- /dev/null +++ b/internal/dev-entrypoint/http.mjs @@ -0,0 +1,134 @@ +import { createServer, request as httpRequest } from "node:http"; + +import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../protocol/index.mjs"; + +export function parsePort(value, fallback) { + const parsed = Number.parseInt(value ?? "", 10); + if (Number.isInteger(parsed) && parsed > 0 && parsed < 65536) { + return parsed; + } + return fallback; +} + +export function resolveHostPort({ listenEnv, hostEnv, portEnv, fallbackPort }) { + const listen = process.env[listenEnv] ?? ""; + const [listenHost, listenPort] = listen.includes(":") ? listen.split(":") : ["", listen]; + const host = process.env[hostEnv] || listenHost || "0.0.0.0"; + const port = parsePort(process.env[portEnv] || listenPort, fallbackPort); + return { host, port }; +} + +export function healthPayload({ serviceId, role, details = {} }) { + const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown"; + const imageReference = process.env.HWLAB_IMAGE || `ghcr.io/pikastech/${serviceId}:${commitId.slice(0, 7) || "unknown"}`; + const imageTag = process.env.HWLAB_IMAGE_TAG || commitId.slice(0, 7) || "unknown"; + + return { + serviceId, + environment: ENVIRONMENT_DEV, + status: "ok", + service: { + id: serviceId, + role, + healthPath: "/health", + livePath: "/health/live" + }, + commit: { + id: commitId, + source: process.env.HWLAB_BUILD_SOURCE || "runtime-env" + }, + image: { + reference: imageReference, + tag: imageTag, + digest: process.env.HWLAB_IMAGE_DIGEST || "unknown" + }, + endpoint: process.env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT, + observedAt: new Date().toISOString(), + details + }; +} + +export function sendJson(response, statusCode, body) { + const payload = JSON.stringify(body, null, 2); + response.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload) + }); + response.end(payload); +} + +export function createServiceServer({ serviceId, role, routes = new Map(), details = () => ({}) }) { + return createServer(async (request, response) => { + try { + const url = new URL(request.url || "/", `http://${serviceId}.local`); + if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) { + sendJson(response, 200, healthPayload({ serviceId, role, details: details() })); + return; + } + + if (request.method === "GET" && url.pathname === "/help") { + sendJson(response, 200, { + serviceId, + commands: ["GET /health", "GET /health/live", ...routes.keys()] + }); + return; + } + + const handler = routes.get(`${request.method ?? "GET"} ${url.pathname}`); + if (!handler) { + sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname }); + return; + } + + sendJson(response, 200, await handler({ request, url })); + } catch (error) { + sendJson(response, 500, { + error: "internal_error", + serviceId, + message: error instanceof Error ? error.message : String(error) + }); + } + }); +} + +export function listen(server, { serviceId, host, port }) { + server.listen(port, host, () => { + process.stdout.write(`${JSON.stringify({ serviceId, status: "listening", host, port })}\n`); + }); + + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { + server.close(() => { + process.exit(0); + }); + }); + } +} + +export function proxyHttpRequest({ request, response, upstream }) { + const target = new URL(request.url || "/", upstream); + const proxy = httpRequest( + target, + { + method: request.method, + headers: { + ...request.headers, + host: target.host + } + }, + (upstreamResponse) => { + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + } + ); + + proxy.on("error", (error) => { + sendJson(response, 502, { + error: "upstream_unavailable", + upstream, + message: error.message + }); + }); + + request.pipe(proxy); +} diff --git a/reports/dev-gate/dev-artifacts.json b/reports/dev-gate/dev-artifacts.json index c56b681a..0843a938 100644 --- a/reports/dev-gate/dev-artifacts.json +++ b/reports/dev-gate/dev-artifacts.json @@ -4,7 +4,7 @@ "reportVersion": "v1", "issue": "pikasTech/HWLAB#35", "taskId": "dev-artifact-publish", - "commitId": "70bb916", + "commitId": "7e0ccb0", "acceptanceLevel": "dev_artifact_publish", "devOnly": true, "prodDisabled": true, @@ -65,24 +65,6 @@ "scope": "base-image", "status": "open", "summary": "HWLAB_DEV_BASE_IMAGE is not set and no approved local DEV builder base image was found in the Docker image cache; expected node:20-bookworm-slim or 127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim." - }, - { - "type": "runtime_blocker", - "scope": "hwlab-router", - "status": "open", - "summary": "hwlab-router has no cmd/hwlab-router/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation." - }, - { - "type": "runtime_blocker", - "scope": "hwlab-tunnel-client", - "status": "open", - "summary": "hwlab-tunnel-client has no cmd/hwlab-tunnel-client/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation." - }, - { - "type": "runtime_blocker", - "scope": "hwlab-edge-proxy", - "status": "open", - "summary": "hwlab-edge-proxy has no cmd/hwlab-edge-proxy/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation." } ], "artifactPublish": { @@ -90,7 +72,7 @@ "status": "blocked", "mode": "preflight", "repo": "pikasTech/HWLAB", - "sourceCommitId": "70bb9168ead272ec1f77b752df8e178e70858df4", + "sourceCommitId": "7e0ccb037f5680a21ee454276182da2012693e61", "registryPrefix": "127.0.0.1:5000/hwlab", "baseImage": null, "baseImagePreflight": { @@ -197,149 +179,162 @@ "serviceCount": 13, "builtCount": 0, "publishedCount": 0, - "generatedAt": "2026-05-21T17:31:56.761Z", + "generatedAt": "2026-05-21T18:14:22.229Z", "services": [ { "serviceId": "hwlab-cloud-api", "status": "preflight_only", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:70bb916", - "imageTag": "70bb916", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:7e0ccb0", + "imageTag": "7e0ccb0", "digest": "not_published", "repositoryDigest": null, "runtimeKind": "node-command", "implementationState": "repo-entrypoint", + "sourceState": "source-present", "entrypoint": "cmd/hwlab-cloud-api/main.mjs" }, { "serviceId": "hwlab-cloud-web", "status": "preflight_only", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:70bb916", - "imageTag": "70bb916", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:7e0ccb0", + "imageTag": "7e0ccb0", "digest": "not_published", "repositoryDigest": null, "runtimeKind": "cloud-web", "implementationState": "static-web-wrapper", + "sourceState": "source-present", "entrypoint": "web/hwlab-cloud-web/index.html" }, { "serviceId": "hwlab-agent-mgr", "status": "preflight_only", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:70bb916", - "imageTag": "70bb916", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:7e0ccb0", + "imageTag": "7e0ccb0", "digest": "not_published", "repositoryDigest": null, "runtimeKind": "node-command", "implementationState": "repo-entrypoint", + "sourceState": "source-present", "entrypoint": "cmd/hwlab-agent-mgr/main.mjs" }, { "serviceId": "hwlab-agent-worker", "status": "preflight_only", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:70bb916", - "imageTag": "70bb916", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:7e0ccb0", + "imageTag": "7e0ccb0", "digest": "not_published", "repositoryDigest": null, "runtimeKind": "node-command", "implementationState": "repo-entrypoint", + "sourceState": "source-present", "entrypoint": "cmd/hwlab-agent-worker/main.mjs" }, { "serviceId": "hwlab-gateway", "status": "preflight_only", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:70bb916", - "imageTag": "70bb916", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:7e0ccb0", + "imageTag": "7e0ccb0", "digest": "not_published", "repositoryDigest": null, "runtimeKind": "node-command", "implementationState": "repo-entrypoint", + "sourceState": "source-present", "entrypoint": "cmd/hwlab-gateway/main.mjs" }, { "serviceId": "hwlab-gateway-simu", "status": "preflight_only", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:70bb916", - "imageTag": "70bb916", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:7e0ccb0", + "imageTag": "7e0ccb0", "digest": "not_published", "repositoryDigest": null, "runtimeKind": "node-command", "implementationState": "repo-entrypoint", + "sourceState": "source-present", "entrypoint": "cmd/hwlab-gateway-simu/main.mjs" }, { "serviceId": "hwlab-box-simu", "status": "preflight_only", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:70bb916", - "imageTag": "70bb916", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:7e0ccb0", + "imageTag": "7e0ccb0", "digest": "not_published", "repositoryDigest": null, "runtimeKind": "node-command", "implementationState": "repo-entrypoint", + "sourceState": "source-present", "entrypoint": "cmd/hwlab-box-simu/main.mjs" }, { "serviceId": "hwlab-patch-panel", "status": "preflight_only", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:70bb916", - "imageTag": "70bb916", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:7e0ccb0", + "imageTag": "7e0ccb0", "digest": "not_published", "repositoryDigest": null, "runtimeKind": "node-command", "implementationState": "repo-entrypoint", + "sourceState": "source-present", "entrypoint": "cmd/hwlab-patch-panel/main.mjs" }, { "serviceId": "hwlab-router", "status": "preflight_only", - "image": "127.0.0.1:5000/hwlab/hwlab-router:70bb916", - "imageTag": "70bb916", + "image": "127.0.0.1:5000/hwlab/hwlab-router:7e0ccb0", + "imageTag": "7e0ccb0", "digest": "not_published", "repositoryDigest": null, - "runtimeKind": "health-placeholder", - "implementationState": "missing-runtime-entrypoint", - "entrypoint": null + "runtimeKind": "node-command", + "implementationState": "repo-entrypoint", + "sourceState": "source-present", + "entrypoint": "cmd/hwlab-router/main.mjs" }, { "serviceId": "hwlab-tunnel-client", "status": "preflight_only", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:70bb916", - "imageTag": "70bb916", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:7e0ccb0", + "imageTag": "7e0ccb0", "digest": "not_published", "repositoryDigest": null, - "runtimeKind": "health-placeholder", - "implementationState": "missing-runtime-entrypoint", - "entrypoint": null + "runtimeKind": "node-command", + "implementationState": "repo-entrypoint", + "sourceState": "source-present", + "entrypoint": "cmd/hwlab-tunnel-client/main.mjs" }, { "serviceId": "hwlab-edge-proxy", "status": "preflight_only", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:70bb916", - "imageTag": "70bb916", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:7e0ccb0", + "imageTag": "7e0ccb0", "digest": "not_published", "repositoryDigest": null, - "runtimeKind": "health-placeholder", - "implementationState": "missing-runtime-entrypoint", - "entrypoint": null + "runtimeKind": "node-command", + "implementationState": "repo-entrypoint", + "sourceState": "source-present", + "entrypoint": "cmd/hwlab-edge-proxy/main.mjs" }, { "serviceId": "hwlab-cli", "status": "preflight_only", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:70bb916", - "imageTag": "70bb916", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:7e0ccb0", + "imageTag": "7e0ccb0", "digest": "not_published", "repositoryDigest": null, "runtimeKind": "cli", "implementationState": "repo-entrypoint", + "sourceState": "source-present", "entrypoint": "tools/hwlab-cli/bin/hwlab-cli.mjs" }, { "serviceId": "hwlab-agent-skills", "status": "preflight_only", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:70bb916", - "imageTag": "70bb916", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:7e0ccb0", + "imageTag": "7e0ccb0", "digest": "not_published", "repositoryDigest": null, "runtimeKind": "skills-bundle", "implementationState": "repo-bundle", + "sourceState": "source-present", "entrypoint": "skills/hwlab-agent-runtime/SKILL.md" } ], @@ -350,27 +345,6 @@ "status": "open", "summary": "HWLAB_DEV_BASE_IMAGE is not set and no approved local DEV builder base image was found in the Docker image cache; expected node:20-bookworm-slim or 127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim.", "next": "Preload node:20-bookworm-slim into the D601 Docker cache, or tag it as 127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim. Set HWLAB_DEV_BASE_IMAGE=node:20-bookworm-slim or HWLAB_DEV_BASE_IMAGE=127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim when invoking artifact publish. Rerun this preflight and require status=ready before #35 artifact publish. Do not use UniDesk runtime, Code Queue runner, backend-core, provider-gateway, or microservice-proxy images as substitutes." - }, - { - "type": "runtime_blocker", - "scope": "hwlab-router", - "status": "open", - "summary": "hwlab-router has no cmd/hwlab-router/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation.", - "next": "Add cmd/hwlab-router/main.mjs or a dedicated Dockerfile for hwlab-router." - }, - { - "type": "runtime_blocker", - "scope": "hwlab-tunnel-client", - "status": "open", - "summary": "hwlab-tunnel-client has no cmd/hwlab-tunnel-client/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation.", - "next": "Add cmd/hwlab-tunnel-client/main.mjs or a dedicated Dockerfile for hwlab-tunnel-client." - }, - { - "type": "runtime_blocker", - "scope": "hwlab-edge-proxy", - "status": "open", - "summary": "hwlab-edge-proxy has no cmd/hwlab-edge-proxy/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation.", - "next": "Add cmd/hwlab-edge-proxy/main.mjs or a dedicated Dockerfile for hwlab-edge-proxy." } ] }, diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index b7700712..a6d6c7d0 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -34,6 +34,10 @@ const buildableImplementationStates = new Set([ "static-web-wrapper", "repo-bundle" ]); +const sourceStates = new Set([ + "source-present", + "intentionally-disabled" +]); const servicePorts = new Map([ ["hwlab-cloud-api", 6667], @@ -285,14 +289,20 @@ function repoLabelFromRemote(remoteUrl) { } } -async function resolveService(serviceId) { +function serviceSourceState(catalog, serviceId) { + return catalog?.services?.find((service) => service.serviceId === serviceId)?.sourceState ?? "source-present"; +} + +async function resolveService(serviceId, catalog) { + const sourceState = serviceSourceState(catalog, serviceId); const cmdPath = `cmd/${serviceId}/main.mjs`; if (await pathExists(cmdPath)) { return { serviceId, runtimeKind: "node-command", entrypoint: cmdPath, - implementationState: "repo-entrypoint" + implementationState: "repo-entrypoint", + sourceState }; } @@ -301,7 +311,8 @@ async function resolveService(serviceId) { serviceId, runtimeKind: "cloud-web", entrypoint: "web/hwlab-cloud-web/index.html", - implementationState: "static-web-wrapper" + implementationState: "static-web-wrapper", + sourceState }; } @@ -311,7 +322,8 @@ async function resolveService(serviceId) { serviceId, runtimeKind: "cli", entrypoint: hasBin ? "tools/hwlab-cli/bin/hwlab-cli.mjs" : "tools/hwlab-cli/lib/cli.mjs", - implementationState: hasBin ? "repo-entrypoint" : "library-only" + implementationState: hasBin ? "repo-entrypoint" : "library-only", + sourceState }; } @@ -320,7 +332,8 @@ async function resolveService(serviceId) { serviceId, runtimeKind: "skills-bundle", entrypoint: "skills/hwlab-agent-runtime/SKILL.md", - implementationState: "repo-bundle" + implementationState: "repo-bundle", + sourceState }; } @@ -328,16 +341,17 @@ async function resolveService(serviceId) { serviceId, runtimeKind: "health-placeholder", entrypoint: null, - implementationState: "missing-runtime-entrypoint" + implementationState: "missing-runtime-entrypoint", + sourceState }; } -async function resolveServices(serviceIds) { +async function resolveServices(serviceIds, catalog) { const unknown = serviceIds.filter((serviceId) => !SERVICE_IDS.includes(serviceId)); assert.deepEqual(unknown, [], `unknown service IDs: ${unknown.join(", ")}`); const services = []; for (const serviceId of serviceIds) { - services.push(await resolveService(serviceId)); + services.push(await resolveService(serviceId, catalog)); } return services; } @@ -504,6 +518,15 @@ function tailText(value, maxLength = 2500) { return text.slice(text.length - maxLength); } +function sourceStateBlocker(service) { + return blocker({ + type: "contract_blocker", + scope: service.serviceId, + summary: `${service.serviceId} has invalid sourceState ${service.sourceState ?? "unset"}.`, + next: `Set ${service.serviceId} sourceState to source-present or intentionally-disabled.` + }); +} + async function preflight({ args, services, catalog, deployManifest, baseImagePreflight }) { const blockers = []; let registryPrefix = args.registryPrefix; @@ -571,7 +594,18 @@ async function preflight({ args, services, catalog, deployManifest, baseImagePre } for (const service of services) { - if (service.implementationState === "missing-runtime-entrypoint") { + if (!sourceStates.has(service.sourceState)) { + blockers.push(sourceStateBlocker(service)); + } else if (service.sourceState === "intentionally-disabled") { + blockers.push( + blocker({ + type: "runtime_blocker", + scope: service.serviceId, + summary: `${service.serviceId} is intentionally disabled for MVP artifact publication.`, + next: `Keep ${service.serviceId} out of the MVP artifact catalog, or add a source entrypoint before publishing it.` + }) + ); + } else if (service.implementationState === "missing-runtime-entrypoint") { blockers.push( blocker({ type: "runtime_blocker", @@ -809,7 +843,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, artifacts, bloc $schema: "https://hwlab.pikastech.local/schemas/dev-artifact-publish.schema.json", $id: "https://hwlab.pikastech.local/reports/dev-gate/dev-artifacts.json", reportVersion: "v1", - issue: "pikasTech/HWLAB#31", + issue: "pikasTech/HWLAB#35", taskId: "dev-artifact-publish", commitId: shortCommit, acceptanceLevel: "dev_artifact_publish", @@ -892,6 +926,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, artifacts, bloc repositoryDigest: artifact.repositoryDigest ?? null, runtimeKind: artifact.runtimeKind, implementationState: artifact.implementationState, + sourceState: artifact.sourceState, entrypoint: artifact.entrypoint, localImageId: artifact.localImageId })), @@ -929,7 +964,7 @@ async function main() { ]); const shortCommit = commitId.slice(0, 7); const repo = repoLabelFromRemote(remoteUrl); - const services = await resolveServices(args.services); + const services = await resolveServices(args.services, catalog); let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight }); let artifacts = services.map((service) => ({ diff --git a/scripts/src/dev-gate-preflight.mjs b/scripts/src/dev-gate-preflight.mjs index 11012c57..ee5c768a 100644 --- a/scripts/src/dev-gate-preflight.mjs +++ b/scripts/src/dev-gate-preflight.mjs @@ -473,6 +473,11 @@ function validateArtifactPublishReport(reporter, artifactReport, targetShortComm const artifactPublish = artifactReport.artifactPublish; const services = artifactPublish?.services ?? []; + const sourcePresentCount = services.filter((service) => service.sourceState === "source-present").length; + const intentionallyDisabledCount = services.filter((service) => service.sourceState === "intentionally-disabled").length; + const sourcePresenceResolved = + services.length === SERVICE_IDS.length && + services.every((service) => service.sourceState === "source-present" || service.sourceState === "intentionally-disabled"); const allServicesPublished = services.length === SERVICE_IDS.length && services.every((service) => service.status === "published" && /^sha256:[a-f0-9]{64}$/.test(service.digest)); const sourceMatchesTarget = [targetCommit, targetShortCommit].includes(artifactPublish?.sourceCommitId) || @@ -489,16 +494,19 @@ function validateArtifactPublishReport(reporter, artifactReport, targetShortComm const publishedCount = artifactPublish?.publishedCount ?? 0; const serviceCount = artifactPublish?.serviceCount ?? SERVICE_IDS.length; + const sourceSummary = sourcePresenceResolved + ? `source states resolved: ${sourcePresentCount} source-present, ${intentionallyDisabledCount} intentionally-disabled` + : "source states are not fully resolved"; reporter.check( "dev-artifact-publish-report", "registry", "blocked", - `DEV artifact publish report status is ${artifactPublish?.status ?? "missing"} with ${publishedCount}/${serviceCount} published for source ${artifactPublish?.sourceCommitId ?? artifactReport.commitId ?? "unknown"}.` + `DEV artifact publish report status is ${artifactPublish?.status ?? "missing"} with ${publishedCount}/${serviceCount} published for source ${artifactPublish?.sourceCommitId ?? artifactReport.commitId ?? "unknown"}; ${sourceSummary}.` ); reporter.block({ type: "runtime_blocker", scope: "dev-artifact-publish", - summary: `reports/dev-gate/dev-artifacts.json does not prove all HWLAB service artifacts for ${targetRef} ${targetShortCommit}; current status is ${artifactPublish?.status ?? "missing"} with ${publishedCount}/${serviceCount} published.`, + summary: `reports/dev-gate/dev-artifacts.json does not prove all HWLAB service artifacts for ${targetRef} ${targetShortCommit}; current status is ${artifactPublish?.status ?? "missing"} with ${publishedCount}/${serviceCount} published and ${sourceSummary}.`, nextTask: "Complete DEV artifact publishing for every frozen HWLAB service at the current origin/main commit and record immutable registry digests." }); } diff --git a/scripts/validate-artifact-catalog.mjs b/scripts/validate-artifact-catalog.mjs index 973d031c..8ed121c9 100644 --- a/scripts/validate-artifact-catalog.mjs +++ b/scripts/validate-artifact-catalog.mjs @@ -17,6 +17,7 @@ const digestPattern = /^sha256:[a-f0-9]{64}$/; const mutableTags = new Set(["latest", "dev", "main", "master", "prod", "production"]); const catalogModes = new Set(["contract-skeleton", "published"]); const skeletonProvenances = new Set(["not_available_in_mvp_skeleton", "not_available_until_publish"]); +const sourceStates = new Set(["source-present", "intentionally-disabled"]); const requiredForbiddenItems = [ "prod-deploy", "prod-profile-enabled", @@ -156,6 +157,10 @@ function assertCatalogServices(catalog, deployManifest, catalogMode) { assert.equal(service.namespace, deployService.namespace, `${context} namespace must match deploy manifest`); assert.equal(service.healthPath, catalog.healthContract.path, `${context} healthPath`); assert.equal(service.healthPath, deployService.healthPath, `${context} healthPath must match deploy manifest`); + assert.ok(sourceStates.has(service.sourceState), `${context} sourceState must be source-present or intentionally-disabled`); + if (service.sourceState === "intentionally-disabled") { + assert.equal(deployService.replicas, 0, `${context} intentionally-disabled services must have zero deploy replicas`); + } if (catalogMode === "contract-skeleton") { assert.equal(service.publishState, "skeleton-only", `${context} publishState`); assert.equal(service.digest, "not_published", `${context} digest`); diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index d5d7cd88..c059d0d5 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -196,6 +196,7 @@ const blockerTypes = new Set([ "safety_blocker" ]); const blockerStates = new Set(["open", "acknowledged", "closed"]); +const artifactSourceStates = new Set(["source-present", "intentionally-disabled"]); const preflightConclusions = new Set(["ready", "blocked"]); const requiredPreflightSupports = [ "pikasTech/HWLAB#7", @@ -407,6 +408,26 @@ async function validateReport(relativePath) { assertStatus(report.gateStatus, `${label}.gateStatus`); } + if (Object.hasOwn(report, "artifactPublish")) { + assertObject(report.artifactPublish, `${label}.artifactPublish`); + assertArray(report.artifactPublish.services ?? [], `${label}.artifactPublish.services`); + for (const [index, service] of report.artifactPublish.services.entries()) { + const serviceLabel = `${label}.artifactPublish.services[${index}]`; + assertObject(service, serviceLabel); + assertString(service.serviceId, `${serviceLabel}.serviceId`); + assertString(service.status, `${serviceLabel}.status`); + assertString(service.implementationState, `${serviceLabel}.implementationState`); + assertString(service.sourceState, `${serviceLabel}.sourceState`); + assert.ok( + artifactSourceStates.has(service.sourceState), + `${serviceLabel}.sourceState must be source-present or intentionally-disabled` + ); + if (service.sourceState === "intentionally-disabled") { + assert.equal(service.entrypoint, null, `${serviceLabel}.entrypoint must be null when intentionally disabled`); + } + } + } + if (Object.hasOwn(report, "milestones")) { assertArray(report.milestones, `${label}.milestones`); assert.deepEqual(