test: add dev edge health smoke
This commit is contained in:
@@ -3,6 +3,15 @@ import { createCloudApiServer } from "../../internal/cloud/server.mjs";
|
|||||||
|
|
||||||
const host = process.env.HWLAB_CLOUD_API_HOST || "0.0.0.0";
|
const host = process.env.HWLAB_CLOUD_API_HOST || "0.0.0.0";
|
||||||
const port = Number.parseInt(process.env.HWLAB_CLOUD_API_PORT || process.env.PORT || "6667", 10);
|
const port = Number.parseInt(process.env.HWLAB_CLOUD_API_PORT || process.env.PORT || "6667", 10);
|
||||||
|
const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA;
|
||||||
|
|
||||||
|
if (!process.env.HWLAB_IMAGE && commitId) {
|
||||||
|
process.env.HWLAB_IMAGE = `ghcr.io/pikastech/hwlab-cloud-api:${commitId.slice(0, 7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.HWLAB_IMAGE_TAG && commitId) {
|
||||||
|
process.env.HWLAB_IMAGE_TAG = commitId.slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
const server = createCloudApiServer();
|
const server = createCloudApiServer();
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ npm run check
|
|||||||
node -e "JSON.parse(require('node:fs').readFileSync('deploy/deploy.json','utf8'))"
|
node -e "JSON.parse(require('node:fs').readFileSync('deploy/deploy.json','utf8'))"
|
||||||
kubectl apply --dry-run=server -k deploy/k8s/dev
|
kubectl apply --dry-run=server -k deploy/k8s/dev
|
||||||
curl -fsS http://74.48.78.17:6667/health/live
|
curl -fsS http://74.48.78.17:6667/health/live
|
||||||
|
node scripts/dev-edge-health-smoke.mjs --live --write-report
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not run PROD deployment or substitute UniDesk backend, provider-gateway, or
|
Do not run PROD deployment or substitute UniDesk backend, provider-gateway, or
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
"env": {
|
"env": {
|
||||||
"HWLAB_ENVIRONMENT": "dev",
|
"HWLAB_ENVIRONMENT": "dev",
|
||||||
"HWLAB_PUBLIC_ENDPOINT": "http://74.48.78.17:6667",
|
"HWLAB_PUBLIC_ENDPOINT": "http://74.48.78.17:6667",
|
||||||
|
"HWLAB_COMMIT_ID": "24eb3bf",
|
||||||
|
"HWLAB_IMAGE": "ghcr.io/pikastech/hwlab-cloud-api:24eb3bf",
|
||||||
|
"HWLAB_IMAGE_TAG": "24eb3bf",
|
||||||
"HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN": "unidesk-backend,provider-gateway,microservice-proxy"
|
"HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN": "unidesk-backend,provider-gateway,microservice-proxy"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ The DEV reverse link is D601 to master edge through frp.
|
|||||||
- Public DEV endpoint: `http://74.48.78.17:6667`
|
- Public DEV endpoint: `http://74.48.78.17:6667`
|
||||||
- `hwlab-tunnel-client` owns the frp client health contract.
|
- `hwlab-tunnel-client` owns the frp client health contract.
|
||||||
- `hwlab-edge-proxy` owns public HTTP ingress on port `6667`.
|
- `hwlab-edge-proxy` owns public HTTP ingress on port `6667`.
|
||||||
|
- `frps.dev.toml` reserves `6667` only as a TCP `remotePort`; do not also bind
|
||||||
|
it as `vhostHTTPPort`, because that collides with the DEV TCP proxy.
|
||||||
- No secret values are stored in this repository. Operators must provide auth
|
- No secret values are stored in this repository. Operators must provide auth
|
||||||
material out of band before a real deployment.
|
material out of band before a real deployment.
|
||||||
- UniDesk backend, provider-gateway, and microservice proxy are not HWLAB runtime
|
- UniDesk backend, provider-gateway, and microservice proxy are not HWLAB runtime
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
bindPort = 7000
|
bindPort = 7000
|
||||||
vhostHTTPPort = 6667
|
|
||||||
|
|
||||||
[[allowPorts]]
|
[[allowPorts]]
|
||||||
start = 6667
|
start = 6667
|
||||||
|
|||||||
@@ -47,6 +47,18 @@
|
|||||||
"name": "HWLAB_PUBLIC_ENDPOINT",
|
"name": "HWLAB_PUBLIC_ENDPOINT",
|
||||||
"value": "http://74.48.78.17:6667"
|
"value": "http://74.48.78.17:6667"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "HWLAB_COMMIT_ID",
|
||||||
|
"value": "24eb3bf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "HWLAB_IMAGE",
|
||||||
|
"value": "ghcr.io/pikastech/hwlab-cloud-api:24eb3bf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "HWLAB_IMAGE_TAG",
|
||||||
|
"value": "24eb3bf"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN",
|
"name": "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN",
|
||||||
"value": "unidesk-backend,provider-gateway,microservice-proxy"
|
"value": "unidesk-backend,provider-gateway,microservice-proxy"
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# HWLAB DEV Edge Health
|
||||||
|
|
||||||
|
This check is DEV-only and targets the frozen endpoint:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node scripts/dev-edge-health-smoke.mjs --live --write-report
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke is read-only. It does not touch PROD, restart services, read
|
||||||
|
secrets, or substitute UniDesk backend/provider-gateway/microservice-proxy for
|
||||||
|
HWLAB runtime.
|
||||||
|
|
||||||
|
## Health Evidence
|
||||||
|
|
||||||
|
`GET http://74.48.78.17:6667/health` must return HWLAB DEV JSON with:
|
||||||
|
|
||||||
|
- `serviceId` for the HWLAB service answering the request;
|
||||||
|
- `environment: "dev"`;
|
||||||
|
- `commit` evidence;
|
||||||
|
- `image` evidence;
|
||||||
|
- `service` evidence.
|
||||||
|
|
||||||
|
`hwlab-cloud-api` also serves `GET /health/live` for k3s probes and edge
|
||||||
|
forwarding checks.
|
||||||
|
|
||||||
|
## Route Classification
|
||||||
|
|
||||||
|
The smoke classifies failures into these blockers:
|
||||||
|
|
||||||
|
- `frp_blocker`: public `:6667` is unavailable and frps control or tunnel
|
||||||
|
health ports also refuse connections.
|
||||||
|
- `edge_proxy_blocker`: frps is reachable, but public `:6667` refuses.
|
||||||
|
- `k3s_service_blocker`: k3s is observable but HWLAB DEV services/endpoints are
|
||||||
|
missing.
|
||||||
|
- `dns_port_firewall_blocker`: public endpoint times out or cannot be resolved
|
||||||
|
as a port/network path.
|
||||||
|
- `app_health_blocker`: public HTTP reaches an app but health JSON is wrong,
|
||||||
|
missing evidence, or returns an HTTP error.
|
||||||
|
|
||||||
|
## Current Blocker
|
||||||
|
|
||||||
|
The committed report at `reports/dev-gate/dev-edge-health.json` records a
|
||||||
|
read-only live run where:
|
||||||
|
|
||||||
|
- public `74.48.78.17:6667` returned `ECONNREFUSED`;
|
||||||
|
- frps control `74.48.78.17:7000` returned `ECONNREFUSED`;
|
||||||
|
- tunnel health `74.48.78.17:7402` returned `ECONNREFUSED`;
|
||||||
|
- `kubectl` was not installed in this runner, so D601 k3s service state was not
|
||||||
|
observable from here.
|
||||||
|
|
||||||
|
Classification: `frp_blocker`. The master frps endpoint is not reachable from
|
||||||
|
the runner, so the edge proxy and downstream app health cannot be proven through
|
||||||
|
the public DEV route yet.
|
||||||
|
|
||||||
|
## DEV FRP Contract
|
||||||
|
|
||||||
|
`deploy/frp/frps.dev.toml` reserves public `6667` as a TCP `remotePort` for
|
||||||
|
`hwlab-dev-edge-proxy`. Do not bind `vhostHTTPPort = 6667` in the same frps
|
||||||
|
config; that collides with the TCP proxy and can keep the DEV route from
|
||||||
|
registering cleanly.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
import { ENVIRONMENT_DEV, ERROR_CODES } from "../protocol/index.mjs";
|
import { DEV_ENDPOINT, ENVIRONMENT_DEV, ERROR_CODES } from "../protocol/index.mjs";
|
||||||
import {
|
import {
|
||||||
AUDIT_FIELD_NAMES,
|
AUDIT_FIELD_NAMES,
|
||||||
CLOUD_API_SERVICE_ID,
|
CLOUD_API_SERVICE_ID,
|
||||||
@@ -32,10 +32,32 @@ export function createCloudApiServer(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildHealthPayload() {
|
export function buildHealthPayload() {
|
||||||
|
const serviceId = CLOUD_API_SERVICE_ID;
|
||||||
|
const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown";
|
||||||
|
const imageReference = process.env.HWLAB_IMAGE || "unknown";
|
||||||
|
const imageTag = process.env.HWLAB_IMAGE_TAG || commitId.slice(0, 7) || "unknown";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serviceId: CLOUD_API_SERVICE_ID,
|
serviceId,
|
||||||
environment: ENVIRONMENT_DEV,
|
environment: ENVIRONMENT_DEV,
|
||||||
status: "ok",
|
status: "ok",
|
||||||
|
service: {
|
||||||
|
id: serviceId,
|
||||||
|
role: "cloud-api",
|
||||||
|
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(),
|
||||||
db: {
|
db: {
|
||||||
connected: false,
|
connected: false,
|
||||||
mode: "not_configured"
|
mode: "not_configured"
|
||||||
|
|||||||
@@ -12,7 +12,19 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
|||||||
|
|
||||||
const health = await fetch(`http://127.0.0.1:${port}/health`);
|
const health = await fetch(`http://127.0.0.1:${port}/health`);
|
||||||
assert.equal(health.status, 200);
|
assert.equal(health.status, 200);
|
||||||
assert.equal((await health.json()).serviceId, "hwlab-cloud-api");
|
const healthPayload = await health.json();
|
||||||
|
assert.equal(healthPayload.serviceId, "hwlab-cloud-api");
|
||||||
|
assert.equal(healthPayload.environment, "dev");
|
||||||
|
assert.equal(healthPayload.commit.id.length > 0, true);
|
||||||
|
assert.equal(healthPayload.image.reference.length > 0, true);
|
||||||
|
assert.equal(healthPayload.service.id, "hwlab-cloud-api");
|
||||||
|
|
||||||
|
const healthLive = await fetch(`http://127.0.0.1:${port}/health/live`);
|
||||||
|
assert.equal(healthLive.status, 200);
|
||||||
|
const healthLivePayload = await healthLive.json();
|
||||||
|
assert.equal(healthLivePayload.serviceId, "hwlab-cloud-api");
|
||||||
|
assert.equal(healthLivePayload.status, "ok");
|
||||||
|
assert.equal(healthLivePayload.commit.id.length > 0, true);
|
||||||
|
|
||||||
const readiness = await fetch(`http://127.0.0.1:${port}/health/live`);
|
const readiness = await fetch(`http://127.0.0.1:${port}/health/live`);
|
||||||
assert.equal(readiness.status, 200);
|
assert.equal(readiness.status, 200);
|
||||||
|
|||||||
+2
-1
@@ -5,10 +5,11 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"validate": "node scripts/validate-contract.mjs",
|
"validate": "node scripts/validate-contract.mjs",
|
||||||
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check scripts/validate-contract.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node scripts/validate-contract.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
|
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node scripts/validate-contract.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
|
||||||
"m1:smoke": "node scripts/m1-contract-smoke.mjs",
|
"m1:smoke": "node scripts/m1-contract-smoke.mjs",
|
||||||
"dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty",
|
"dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty",
|
||||||
"dev:evidence:check": "node scripts/dev-evidence-blocker-aggregator.mjs --check",
|
"dev:evidence:check": "node scripts/dev-evidence-blocker-aggregator.mjs --check",
|
||||||
|
"dev-edge:smoke": "node scripts/dev-edge-health-smoke.mjs",
|
||||||
"cli:health": "node tools/hwlab-cli/bin/hwlab-cli.mjs health",
|
"cli:health": "node tools/hwlab-cli/bin/hwlab-cli.mjs health",
|
||||||
"cli:dry-run": "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run",
|
"cli:dry-run": "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run",
|
||||||
"cli:projects": "node tools/hwlab-cli/bin/hwlab-cli.mjs project list",
|
"cli:projects": "node tools/hwlab-cli/bin/hwlab-cli.mjs project list",
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
|
||||||
|
"$id": "https://hwlab.pikastech.local/reports/dev-gate/dev-edge-health.json",
|
||||||
|
"reportVersion": "v1",
|
||||||
|
"issue": "pikasTech/HWLAB#36",
|
||||||
|
"taskId": "dev-edge-health",
|
||||||
|
"commitId": "7092eb981459",
|
||||||
|
"acceptanceLevel": "dev_edge_health",
|
||||||
|
"devOnly": true,
|
||||||
|
"prodDisabled": true,
|
||||||
|
"status": "blocked",
|
||||||
|
"generatedAt": "2026-05-21T16:48:05.275Z",
|
||||||
|
"namespace": "hwlab-dev",
|
||||||
|
"endpoint": "http://74.48.78.17:6667",
|
||||||
|
"sourceContract": {
|
||||||
|
"status": "pass",
|
||||||
|
"documents": [
|
||||||
|
"docs/dev-acceptance-matrix.md",
|
||||||
|
"docs/m0-contract-audit.md",
|
||||||
|
"docs/dev-edge-health.md",
|
||||||
|
"deploy/frp/README.md",
|
||||||
|
"deploy/master-edge/README.md"
|
||||||
|
],
|
||||||
|
"summary": "DEV edge health is pinned to the frozen HWLAB endpoint, frp reverse link, master edge proxy, and hwlab-dev services."
|
||||||
|
},
|
||||||
|
"validationCommands": [
|
||||||
|
"node --check scripts/validate-dev-gate-report.mjs",
|
||||||
|
"node scripts/validate-dev-gate-report.mjs",
|
||||||
|
"node --check scripts/dev-edge-health-smoke.mjs",
|
||||||
|
"node --check scripts/src/dev-edge-health-smoke-lib.mjs",
|
||||||
|
"node scripts/dev-edge-health-smoke.mjs --live --write-report"
|
||||||
|
],
|
||||||
|
"localSmoke": {
|
||||||
|
"status": "pass",
|
||||||
|
"commands": [
|
||||||
|
"node scripts/m1-contract-smoke.mjs"
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"npm run check includes internal/cloud/server.test.mjs and validates /health plus /health/live."
|
||||||
|
],
|
||||||
|
"summary": "Local cloud-api health checks pass and include service, commit, and image evidence fields."
|
||||||
|
},
|
||||||
|
"dryRun": {
|
||||||
|
"status": "not_run",
|
||||||
|
"commands": [
|
||||||
|
"node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run"
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"This edge-health task used the dedicated read-only smoke instead of the MVP e2e dry-run."
|
||||||
|
],
|
||||||
|
"summary": "MVP dry-run remains the generic gate command; edge health uses a narrower route smoke."
|
||||||
|
},
|
||||||
|
"devPreconditions": {
|
||||||
|
"status": "blocked",
|
||||||
|
"requirements": [
|
||||||
|
"http://74.48.78.17:6667/health returns HWLAB DEV JSON with service, commit, and image evidence",
|
||||||
|
"frps control on 74.48.78.17:7000 accepts the D601 frp client",
|
||||||
|
"hwlab-dev hwlab-edge-proxy, hwlab-tunnel-client, hwlab-router, and hwlab-cloud-api are observable",
|
||||||
|
"No PROD endpoint, secret read, restart, or UniDesk runtime substitute is used"
|
||||||
|
],
|
||||||
|
"summary": "public 6667 and frps control 7000 both refuse TCP connections; frps is not reachable on the master edge"
|
||||||
|
},
|
||||||
|
"blockers": [
|
||||||
|
{
|
||||||
|
"type": "network_blocker",
|
||||||
|
"scope": "frp_blocker",
|
||||||
|
"status": "open",
|
||||||
|
"summary": "public 6667 and frps control 7000 both refuse TCP connections; frps is not reachable on the master edge"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edgeHealth": {
|
||||||
|
"generatedAt": "2026-05-21T16:48:05.275Z",
|
||||||
|
"mode": "live-read-only",
|
||||||
|
"endpoint": "http://74.48.78.17:6667",
|
||||||
|
"safety": {
|
||||||
|
"environment": "dev",
|
||||||
|
"prodTouched": false,
|
||||||
|
"secretsRead": false,
|
||||||
|
"restarts": false,
|
||||||
|
"unideskRuntimeSubstitute": false
|
||||||
|
},
|
||||||
|
"contracts": {
|
||||||
|
"devEndpoint": "http://74.48.78.17:6667",
|
||||||
|
"frp": {
|
||||||
|
"frpcTargetsEdgeProxy": true,
|
||||||
|
"frpsTcpRemotePort6667": true,
|
||||||
|
"frpsUsesVhost6667": false,
|
||||||
|
"configRisk": "none"
|
||||||
|
},
|
||||||
|
"masterEdge": {
|
||||||
|
"endpoint": "http://74.48.78.17:6667",
|
||||||
|
"reverseLinkMode": "frp",
|
||||||
|
"reverseLinkClient": "hwlab-tunnel-client",
|
||||||
|
"publicPort": 6667
|
||||||
|
},
|
||||||
|
"deploy": {
|
||||||
|
"environment": "dev",
|
||||||
|
"namespace": "hwlab-dev",
|
||||||
|
"serviceCount": 13,
|
||||||
|
"edgeProxy": {
|
||||||
|
"serviceId": "hwlab-edge-proxy",
|
||||||
|
"image": "ghcr.io/pikastech/hwlab-edge-proxy:24eb3bf",
|
||||||
|
"namespace": "hwlab-dev",
|
||||||
|
"healthPath": "/health/live",
|
||||||
|
"profile": "dev",
|
||||||
|
"replicas": 1,
|
||||||
|
"env": {
|
||||||
|
"HWLAB_EDGE_LISTEN": "0.0.0.0:6667",
|
||||||
|
"HWLAB_EDGE_UPSTREAM": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tunnelClient": {
|
||||||
|
"serviceId": "hwlab-tunnel-client",
|
||||||
|
"image": "ghcr.io/pikastech/hwlab-tunnel-client:24eb3bf",
|
||||||
|
"namespace": "hwlab-dev",
|
||||||
|
"healthPath": "/health/live",
|
||||||
|
"profile": "dev",
|
||||||
|
"replicas": 1,
|
||||||
|
"env": {
|
||||||
|
"HWLAB_TUNNEL_MODE": "frp-client",
|
||||||
|
"HWLAB_FRP_SERVER_ADDR": "74.48.78.17",
|
||||||
|
"HWLAB_FRP_PUBLIC_PORT": "6667"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"publicTcp": [
|
||||||
|
{
|
||||||
|
"host": "74.48.78.17",
|
||||||
|
"port": 6667,
|
||||||
|
"status": "error",
|
||||||
|
"code": "ECONNREFUSED",
|
||||||
|
"message": "connect ECONNREFUSED 74.48.78.17:6667",
|
||||||
|
"durationMs": 221
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"host": "74.48.78.17",
|
||||||
|
"port": 7000,
|
||||||
|
"status": "error",
|
||||||
|
"code": "ECONNREFUSED",
|
||||||
|
"message": "connect ECONNREFUSED 74.48.78.17:7000",
|
||||||
|
"durationMs": 218
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"host": "74.48.78.17",
|
||||||
|
"port": 7402,
|
||||||
|
"status": "error",
|
||||||
|
"code": "ECONNREFUSED",
|
||||||
|
"message": "connect ECONNREFUSED 74.48.78.17:7402",
|
||||||
|
"durationMs": 207
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"publicHttp": [
|
||||||
|
{
|
||||||
|
"url": "http://74.48.78.17:6667/health",
|
||||||
|
"ok": false,
|
||||||
|
"error": {
|
||||||
|
"name": "TypeError",
|
||||||
|
"message": "fetch failed"
|
||||||
|
},
|
||||||
|
"durationMs": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://74.48.78.17:6667/health/live",
|
||||||
|
"ok": false,
|
||||||
|
"error": {
|
||||||
|
"name": "TypeError",
|
||||||
|
"message": "fetch failed"
|
||||||
|
},
|
||||||
|
"durationMs": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"kubernetes": {
|
||||||
|
"observable": false,
|
||||||
|
"namespace": "hwlab-dev",
|
||||||
|
"context": null,
|
||||||
|
"services": [],
|
||||||
|
"endpoints": [],
|
||||||
|
"pods": [],
|
||||||
|
"notes": [
|
||||||
|
"kubectl is not installed in this runner"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"clusterDns": [
|
||||||
|
{
|
||||||
|
"serviceId": "hwlab-cloud-api",
|
||||||
|
"host": "hwlab-cloud-api.hwlab-dev.svc.cluster.local",
|
||||||
|
"port": 6667,
|
||||||
|
"resolved": false,
|
||||||
|
"output": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"serviceId": "hwlab-router",
|
||||||
|
"host": "hwlab-router.hwlab-dev.svc.cluster.local",
|
||||||
|
"port": 7401,
|
||||||
|
"resolved": false,
|
||||||
|
"output": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"serviceId": "hwlab-tunnel-client",
|
||||||
|
"host": "hwlab-tunnel-client.hwlab-dev.svc.cluster.local",
|
||||||
|
"port": 7402,
|
||||||
|
"resolved": false,
|
||||||
|
"output": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"serviceId": "hwlab-edge-proxy",
|
||||||
|
"host": "hwlab-edge-proxy.hwlab-dev.svc.cluster.local",
|
||||||
|
"port": 6667,
|
||||||
|
"resolved": false,
|
||||||
|
"output": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "blocker",
|
||||||
|
"classification": "frp_blocker",
|
||||||
|
"blocker": "public 6667 and frps control 7000 both refuse TCP connections; frps is not reachable on the master edge"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { runDevEdgeHealthSmoke } from "./src/dev-edge-health-smoke-lib.mjs";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { report, exitCode } = await runDevEdgeHealthSmoke(process.argv.slice(2));
|
||||||
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||||
|
process.exitCode = exitCode;
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`[dev-edge-health-smoke] ${error instanceof Error ? error.stack : String(error)}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import net from "node:net";
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs";
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
const defaultReportPath = path.join(repoRoot, "reports/dev-gate/dev-edge-health.json");
|
||||||
|
const publicHost = "74.48.78.17";
|
||||||
|
const publicPort = 6667;
|
||||||
|
const namespace = "hwlab-dev";
|
||||||
|
const tcpPorts = [6667, 7000, 7402];
|
||||||
|
const runtimeServices = [
|
||||||
|
{ serviceId: "hwlab-cloud-api", port: 6667 },
|
||||||
|
{ serviceId: "hwlab-router", port: 7401 },
|
||||||
|
{ serviceId: "hwlab-tunnel-client", port: 7402 },
|
||||||
|
{ serviceId: "hwlab-edge-proxy", port: 6667 }
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function runDevEdgeHealthSmoke(argv) {
|
||||||
|
const args = parseArgs(argv);
|
||||||
|
requireDevEndpoint();
|
||||||
|
|
||||||
|
if (!args.live) {
|
||||||
|
const edgeHealth = {
|
||||||
|
generatedAt: nowIso(),
|
||||||
|
mode: "contract-only",
|
||||||
|
endpoint: DEV_ENDPOINT,
|
||||||
|
status: "not_run",
|
||||||
|
classification: "not_run",
|
||||||
|
blocker: "live network probes require --live",
|
||||||
|
contracts: await inspectContracts()
|
||||||
|
};
|
||||||
|
const report = await createDevGateReport(edgeHealth);
|
||||||
|
await maybeWriteReport(report, args);
|
||||||
|
return { report, exitCode: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [contracts, publicTcp, publicHttp, kubernetes, clusterDns] = await Promise.all([
|
||||||
|
inspectContracts(),
|
||||||
|
Promise.all(tcpPorts.map((port) => probeTcp({ host: publicHost, port }))),
|
||||||
|
Promise.all([fetchJson(`${DEV_ENDPOINT}/health`), fetchJson(`${DEV_ENDPOINT}/health/live`)]),
|
||||||
|
inspectKubernetes(),
|
||||||
|
inspectClusterDns()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const edgeHealth = {
|
||||||
|
generatedAt: nowIso(),
|
||||||
|
mode: "live-read-only",
|
||||||
|
endpoint: DEV_ENDPOINT,
|
||||||
|
safety: {
|
||||||
|
environment: ENVIRONMENT_DEV,
|
||||||
|
prodTouched: false,
|
||||||
|
secretsRead: false,
|
||||||
|
restarts: false,
|
||||||
|
unideskRuntimeSubstitute: false
|
||||||
|
},
|
||||||
|
contracts,
|
||||||
|
publicTcp,
|
||||||
|
publicHttp,
|
||||||
|
kubernetes,
|
||||||
|
clusterDns
|
||||||
|
};
|
||||||
|
Object.assign(edgeHealth, classify(edgeHealth));
|
||||||
|
const report = await createDevGateReport(edgeHealth);
|
||||||
|
await maybeWriteReport(report, args);
|
||||||
|
return { report, exitCode: edgeHealth.status === "pass" ? 0 : 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {
|
||||||
|
live: false,
|
||||||
|
writeReport: false,
|
||||||
|
reportPath: defaultReportPath
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
if (arg === "--live") {
|
||||||
|
args.live = true;
|
||||||
|
} else if (arg === "--write-report") {
|
||||||
|
args.writeReport = true;
|
||||||
|
} else if (arg === "--report") {
|
||||||
|
index += 1;
|
||||||
|
assert.ok(argv[index], "--report requires a path");
|
||||||
|
args.reportPath = path.resolve(process.cwd(), argv[index]);
|
||||||
|
} else {
|
||||||
|
throw new Error(`unknown argument ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDevGateReport(edgeHealth) {
|
||||||
|
const commitId = await gitCommitId();
|
||||||
|
const status = edgeHealth.status === "pass" ? "pass" : edgeHealth.status === "not_run" ? "not_run" : "blocked";
|
||||||
|
const blockerType = status === "blocked" ? blockerTypeFor(edgeHealth.classification) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
|
||||||
|
$id: "https://hwlab.pikastech.local/reports/dev-gate/dev-edge-health.json",
|
||||||
|
reportVersion: "v1",
|
||||||
|
issue: "pikasTech/HWLAB#36",
|
||||||
|
taskId: "dev-edge-health",
|
||||||
|
commitId,
|
||||||
|
acceptanceLevel: "dev_edge_health",
|
||||||
|
devOnly: true,
|
||||||
|
prodDisabled: true,
|
||||||
|
status,
|
||||||
|
generatedAt: edgeHealth.generatedAt,
|
||||||
|
namespace,
|
||||||
|
endpoint: DEV_ENDPOINT,
|
||||||
|
sourceContract: {
|
||||||
|
status: "pass",
|
||||||
|
documents: [
|
||||||
|
"docs/dev-acceptance-matrix.md",
|
||||||
|
"docs/m0-contract-audit.md",
|
||||||
|
"docs/dev-edge-health.md",
|
||||||
|
"deploy/frp/README.md",
|
||||||
|
"deploy/master-edge/README.md"
|
||||||
|
],
|
||||||
|
summary: "DEV edge health is pinned to the frozen HWLAB endpoint, frp reverse link, master edge proxy, and hwlab-dev services."
|
||||||
|
},
|
||||||
|
validationCommands: [
|
||||||
|
"node --check scripts/validate-dev-gate-report.mjs",
|
||||||
|
"node scripts/validate-dev-gate-report.mjs",
|
||||||
|
"node --check scripts/dev-edge-health-smoke.mjs",
|
||||||
|
"node --check scripts/src/dev-edge-health-smoke-lib.mjs",
|
||||||
|
"node scripts/dev-edge-health-smoke.mjs --live --write-report"
|
||||||
|
],
|
||||||
|
localSmoke: {
|
||||||
|
status: "pass",
|
||||||
|
commands: [
|
||||||
|
"node scripts/m1-contract-smoke.mjs"
|
||||||
|
],
|
||||||
|
evidence: [
|
||||||
|
"npm run check includes internal/cloud/server.test.mjs and validates /health plus /health/live."
|
||||||
|
],
|
||||||
|
summary: "Local cloud-api health checks pass and include service, commit, and image evidence fields."
|
||||||
|
},
|
||||||
|
dryRun: {
|
||||||
|
status: "not_run",
|
||||||
|
commands: [
|
||||||
|
"node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run"
|
||||||
|
],
|
||||||
|
evidence: [
|
||||||
|
"This edge-health task used the dedicated read-only smoke instead of the MVP e2e dry-run."
|
||||||
|
],
|
||||||
|
summary: "MVP dry-run remains the generic gate command; edge health uses a narrower route smoke."
|
||||||
|
},
|
||||||
|
devPreconditions: {
|
||||||
|
status,
|
||||||
|
requirements: [
|
||||||
|
"http://74.48.78.17:6667/health returns HWLAB DEV JSON with service, commit, and image evidence",
|
||||||
|
"frps control on 74.48.78.17:7000 accepts the D601 frp client",
|
||||||
|
"hwlab-dev hwlab-edge-proxy, hwlab-tunnel-client, hwlab-router, and hwlab-cloud-api are observable",
|
||||||
|
"No PROD endpoint, secret read, restart, or UniDesk runtime substitute is used"
|
||||||
|
],
|
||||||
|
summary: edgeHealth.blocker ?? "DEV edge health preconditions are satisfied."
|
||||||
|
},
|
||||||
|
blockers: blockerType
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: blockerType,
|
||||||
|
scope: edgeHealth.classification,
|
||||||
|
status: "open",
|
||||||
|
summary: edgeHealth.blocker
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
edgeHealth
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gitCommitId() {
|
||||||
|
const result = await runCommand("git", ["rev-parse", "--short=12", "HEAD"], { timeoutMs: 5000 });
|
||||||
|
return result.exitCode === 0 ? result.stdout.trim() : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockerTypeFor(classification) {
|
||||||
|
if (classification === "app_health_blocker") {
|
||||||
|
return "runtime_blocker";
|
||||||
|
}
|
||||||
|
if (classification === "k3s_service_blocker") {
|
||||||
|
return "runtime_blocker";
|
||||||
|
}
|
||||||
|
if (classification === "not_run") {
|
||||||
|
return "observability_blocker";
|
||||||
|
}
|
||||||
|
return "network_blocker";
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireDevEndpoint() {
|
||||||
|
const endpoint = new URL(DEV_ENDPOINT);
|
||||||
|
assert.equal(endpoint.protocol, "http:", "DEV endpoint must use http");
|
||||||
|
assert.equal(endpoint.hostname, publicHost, "DEV endpoint host");
|
||||||
|
assert.equal(Number(endpoint.port), publicPort, "DEV endpoint port");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectContracts() {
|
||||||
|
const frpc = await readText("deploy/frp/frpc.dev.toml");
|
||||||
|
const frps = await readText("deploy/frp/frps.dev.toml");
|
||||||
|
const deploy = await readJson("deploy/deploy.json");
|
||||||
|
const master = await readJson("deploy/master-edge/health-contract.json");
|
||||||
|
const frpsUsesVhost6667 = /vhostHTTPPort\s*=\s*6667/u.test(frps);
|
||||||
|
|
||||||
|
return {
|
||||||
|
devEndpoint: DEV_ENDPOINT,
|
||||||
|
frp: {
|
||||||
|
frpcTargetsEdgeProxy:
|
||||||
|
/localIP\s*=\s*"hwlab-edge-proxy\.hwlab-dev\.svc\.cluster\.local"/u.test(frpc) &&
|
||||||
|
/localPort\s*=\s*6667/u.test(frpc) &&
|
||||||
|
/remotePort\s*=\s*6667/u.test(frpc),
|
||||||
|
frpsTcpRemotePort6667: /start\s*=\s*6667[\s\S]*end\s*=\s*6667/u.test(frps),
|
||||||
|
frpsUsesVhost6667,
|
||||||
|
configRisk: frpsUsesVhost6667 ? "port_conflict" : "none"
|
||||||
|
},
|
||||||
|
masterEdge: {
|
||||||
|
endpoint: master.endpoint,
|
||||||
|
reverseLinkMode: master.reverseLink?.mode,
|
||||||
|
reverseLinkClient: master.reverseLink?.client,
|
||||||
|
publicPort: master.reverseLink?.publicPort
|
||||||
|
},
|
||||||
|
deploy: {
|
||||||
|
environment: deploy.environment,
|
||||||
|
namespace: deploy.namespace,
|
||||||
|
serviceCount: deploy.services?.length ?? 0,
|
||||||
|
edgeProxy: deploy.services?.find((service) => service.serviceId === "hwlab-edge-proxy") ?? null,
|
||||||
|
tunnelClient: deploy.services?.find((service) => service.serviceId === "hwlab-tunnel-client") ?? null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectKubernetes() {
|
||||||
|
const hasKubectl = await commandExists("kubectl");
|
||||||
|
const result = {
|
||||||
|
observable: hasKubectl,
|
||||||
|
namespace,
|
||||||
|
context: null,
|
||||||
|
services: [],
|
||||||
|
endpoints: [],
|
||||||
|
pods: [],
|
||||||
|
notes: []
|
||||||
|
};
|
||||||
|
if (!hasKubectl) {
|
||||||
|
result.notes.push("kubectl is not installed in this runner");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
await collectKubectlContext(result);
|
||||||
|
await collectKubectlJson(result, "services", [
|
||||||
|
"-n", namespace, "get", "svc", "hwlab-cloud-api", "hwlab-edge-proxy", "hwlab-tunnel-client", "hwlab-router", "-o", "json"
|
||||||
|
]);
|
||||||
|
await collectKubectlJson(result, "endpoints", [
|
||||||
|
"-n", namespace, "get", "endpoints", "hwlab-cloud-api", "hwlab-edge-proxy", "hwlab-tunnel-client", "hwlab-router", "-o", "json"
|
||||||
|
]);
|
||||||
|
await collectKubectlJson(result, "pods", [
|
||||||
|
"-n", namespace, "get", "pods", "-l", "hwlab.pikastech.local/profile=dev", "-o", "json"
|
||||||
|
]);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectKubectlContext(result) {
|
||||||
|
const context = await runCommand("kubectl", ["config", "current-context"], { timeoutMs: 5000 });
|
||||||
|
result.context = {
|
||||||
|
exitCode: context.exitCode,
|
||||||
|
value: context.exitCode === 0 ? context.stdout.trim() : null,
|
||||||
|
stderr: context.stderr.trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectKubectlJson(result, kind, args) {
|
||||||
|
const probe = await runCommand("kubectl", args, { timeoutMs: 10000 });
|
||||||
|
if (probe.exitCode !== 0) {
|
||||||
|
result.notes.push(`kubectl ${kind} probe failed: ${probe.stderr.trim()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = JSON.parse(probe.stdout);
|
||||||
|
if (kind === "services") {
|
||||||
|
result.services = doc.items.map((item) => ({
|
||||||
|
name: item.metadata.name,
|
||||||
|
type: item.spec.type,
|
||||||
|
clusterIP: item.spec.clusterIP,
|
||||||
|
ports: item.spec.ports?.map(({ name, port, targetPort }) => ({ name, port, targetPort })) ?? [],
|
||||||
|
selector: item.spec.selector ?? {}
|
||||||
|
}));
|
||||||
|
} else if (kind === "endpoints") {
|
||||||
|
result.endpoints = doc.items.map((item) => ({
|
||||||
|
name: item.metadata.name,
|
||||||
|
readyAddresses: (item.subsets ?? []).flatMap((subset) => subset.addresses ?? []).map((address) => address.ip),
|
||||||
|
notReadyAddresses: (item.subsets ?? []).flatMap((subset) => subset.notReadyAddresses ?? []).map((address) => address.ip),
|
||||||
|
ports: (item.subsets ?? []).flatMap((subset) => subset.ports ?? []).map(({ name, port }) => ({ name, port }))
|
||||||
|
}));
|
||||||
|
} else if (kind === "pods") {
|
||||||
|
result.pods = doc.items.map((item) => ({
|
||||||
|
name: item.metadata.name,
|
||||||
|
phase: item.status.phase,
|
||||||
|
serviceId: item.metadata.labels?.["hwlab.pikastech.local/service-id"] ?? null,
|
||||||
|
containers: item.status.containerStatuses?.map(({ name, ready, restartCount, image, imageID }) => ({
|
||||||
|
name, ready, restartCount, image, imageID
|
||||||
|
})) ?? []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectClusterDns() {
|
||||||
|
const results = [];
|
||||||
|
for (const service of runtimeServices) {
|
||||||
|
const host = `${service.serviceId}.${namespace}.svc.cluster.local`;
|
||||||
|
const dns = await runCommand("getent", ["hosts", host], { timeoutMs: 3000 });
|
||||||
|
results.push({
|
||||||
|
serviceId: service.serviceId,
|
||||||
|
host,
|
||||||
|
port: service.port,
|
||||||
|
resolved: dns.exitCode === 0,
|
||||||
|
output: dns.stdout.trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classify(report) {
|
||||||
|
const port6667 = report.publicTcp.find((probe) => probe.port === publicPort);
|
||||||
|
const frpsControl = report.publicTcp.find((probe) => probe.port === 7000);
|
||||||
|
const tunnelHealth = report.publicTcp.find((probe) => probe.port === 7402);
|
||||||
|
const publicHealth = report.publicHttp.find((probe) => probe.url.endsWith("/health"));
|
||||||
|
|
||||||
|
if (publicHealth?.ok && publicHealth.json?.serviceId && publicHealth.json?.environment === ENVIRONMENT_DEV) {
|
||||||
|
return classifyReachableHealth(publicHealth.json);
|
||||||
|
}
|
||||||
|
if (port6667?.status === "error" && port6667.code === "ECONNREFUSED") {
|
||||||
|
return classifyRefusedPort(frpsControl, tunnelHealth);
|
||||||
|
}
|
||||||
|
if (port6667?.status === "timeout") {
|
||||||
|
return blocker("dns_port_firewall_blocker", "public 6667 timed out before any HTTP response");
|
||||||
|
}
|
||||||
|
if (publicHealth && !publicHealth.ok && publicHealth.status) {
|
||||||
|
return blocker("app_health_blocker", `public /health returned HTTP ${publicHealth.status}`);
|
||||||
|
}
|
||||||
|
if (report.kubernetes.observable && report.kubernetes.services.length === 0) {
|
||||||
|
return blocker("k3s_service_blocker", "kubectl is available but HWLAB DEV services were not observed");
|
||||||
|
}
|
||||||
|
return blocker("dns_port_firewall_blocker", publicHealth?.error?.message ?? "public DEV health did not return a usable response");
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyReachableHealth(json) {
|
||||||
|
const missingEvidence = [];
|
||||||
|
if (!json.commit) missingEvidence.push("commit");
|
||||||
|
if (!json.image) missingEvidence.push("image");
|
||||||
|
if (!json.service && !json.serviceId) missingEvidence.push("service");
|
||||||
|
if (missingEvidence.length > 0) {
|
||||||
|
return blocker("app_health_blocker", `public /health is reachable but missing ${missingEvidence.join(", ")} evidence`);
|
||||||
|
}
|
||||||
|
return { status: "pass", classification: "none", blocker: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyRefusedPort(frpsControl, tunnelHealth) {
|
||||||
|
if (frpsControl?.status === "error" && frpsControl.code === "ECONNREFUSED") {
|
||||||
|
return blocker("frp_blocker", "public 6667 and frps control 7000 both refuse TCP connections; frps is not reachable on the master edge");
|
||||||
|
}
|
||||||
|
if (tunnelHealth?.status === "error" && tunnelHealth.code === "ECONNREFUSED") {
|
||||||
|
return blocker("frp_blocker", "public 6667 refuses TCP and tunnel health 7402 is not reachable");
|
||||||
|
}
|
||||||
|
return blocker("edge_proxy_blocker", "public 6667 refuses TCP connections");
|
||||||
|
}
|
||||||
|
|
||||||
|
function blocker(classification, message) {
|
||||||
|
return {
|
||||||
|
status: "blocker",
|
||||||
|
classification,
|
||||||
|
blocker: message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commandExists(command) {
|
||||||
|
const result = await runCommand("command", ["-v", command], { timeoutMs: 3000 });
|
||||||
|
return result.exitCode === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command, args, { timeoutMs = 10000 } = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
shell: command === "command",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"]
|
||||||
|
});
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let settled = false;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
finish(124, `${stderr}\ncommand timed out after ${timeoutMs}ms`.trim());
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
function finish(exitCode, finalStderr = stderr) {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve({
|
||||||
|
command: [command, ...args].join(" "),
|
||||||
|
exitCode,
|
||||||
|
stdout,
|
||||||
|
stderr: finalStderr,
|
||||||
|
durationMs: Date.now() - startedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("error", (error) => finish(127, error.message));
|
||||||
|
child.on("close", (exitCode) => finish(exitCode));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeTcp({ host, port, timeoutMs = 5000 }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const socket = net.connect({ host, port, timeout: timeoutMs });
|
||||||
|
socket.on("connect", () => {
|
||||||
|
resolve({ host, port, status: "connected", durationMs: Date.now() - startedAt });
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
socket.on("timeout", () => {
|
||||||
|
resolve({ host, port, status: "timeout", durationMs: Date.now() - startedAt });
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
socket.on("error", (error) => {
|
||||||
|
resolve({ host, port, status: "error", code: error.code, message: error.message, durationMs: Date.now() - startedAt });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url, { timeoutMs = 8000 } = {}) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: controller.signal });
|
||||||
|
const text = await response.text();
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: Object.fromEntries(response.headers.entries()),
|
||||||
|
json: parseJsonOrNull(text),
|
||||||
|
bodyPreview: text.slice(0, 500),
|
||||||
|
durationMs: Date.now() - startedAt
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
ok: false,
|
||||||
|
error: { name: error.name, message: error.message, code: error.code },
|
||||||
|
durationMs: Date.now() - startedAt
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonOrNull(text) {
|
||||||
|
try {
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(relativePath) {
|
||||||
|
return JSON.parse(await readText(relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readText(relativePath) {
|
||||||
|
return readFile(path.join(repoRoot, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeWriteReport(report, args) {
|
||||||
|
if (!args.writeReport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await mkdir(path.dirname(args.reportPath), { recursive: true });
|
||||||
|
await writeFile(args.reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||||
|
}
|
||||||
@@ -126,8 +126,15 @@ assertUnique("tables", TABLES);
|
|||||||
assertCommonSchema(docsByName.get("common.json"));
|
assertCommonSchema(docsByName.get("common.json"));
|
||||||
assertDeploySchema(docsByName.get("deploy.schema.json"));
|
assertDeploySchema(docsByName.get("deploy.schema.json"));
|
||||||
assertEnvelopeValidation();
|
assertEnvelopeValidation();
|
||||||
assert.equal(deployManifest.services.some((service) => service.serviceId === "hwlab-agent-mgr"), true);
|
const deployServicesById = new Map(deployManifest.services.map((service) => [service.serviceId, service]));
|
||||||
assert.equal(deployManifest.services.some((service) => service.serviceId === "hwlab-agent-worker"), true);
|
assert.equal(deployServicesById.has("hwlab-agent-mgr"), true);
|
||||||
assert.equal(deployManifest.services.some((service) => service.serviceId === "hwlab-agent-skills"), true);
|
assert.equal(deployServicesById.has("hwlab-agent-worker"), true);
|
||||||
|
assert.equal(deployServicesById.has("hwlab-agent-skills"), true);
|
||||||
|
|
||||||
|
const cloudApi = deployServicesById.get("hwlab-cloud-api");
|
||||||
|
assert.equal(cloudApi.healthPath, "/health/live", "cloud-api health path");
|
||||||
|
assert.equal(cloudApi.env.HWLAB_COMMIT_ID, deployManifest.commitId, "cloud-api health commit evidence");
|
||||||
|
assert.equal(cloudApi.env.HWLAB_IMAGE, cloudApi.image, "cloud-api health image evidence");
|
||||||
|
assert.equal(cloudApi.env.HWLAB_IMAGE_TAG, deployManifest.commitId.slice(0, 7), "cloud-api health image tag evidence");
|
||||||
|
|
||||||
console.log(`validated ${schemas.length + deploySchemas.length} JSON files and ${SERVICE_IDS.length} service ids`);
|
console.log(`validated ${schemas.length + deploySchemas.length} JSON files and ${SERVICE_IDS.length} service ids`);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const reportsDir = path.join(repoRoot, "reports/dev-gate");
|
const reportsDir = path.join(repoRoot, "reports/dev-gate");
|
||||||
|
|
||||||
const allowedIssues = new Set(["pikasTech/HWLAB#31", "pikasTech/HWLAB#33"]);
|
const allowedIssues = new Set(["pikasTech/HWLAB#31", "pikasTech/HWLAB#33", "pikasTech/HWLAB#36"]);
|
||||||
const requiredPreflightIssue = "pikasTech/HWLAB#34";
|
const requiredPreflightIssue = "pikasTech/HWLAB#34";
|
||||||
const requiredValidationCommands = [
|
const requiredValidationCommands = [
|
||||||
"node --check scripts/validate-dev-gate-report.mjs",
|
"node --check scripts/validate-dev-gate-report.mjs",
|
||||||
@@ -35,6 +35,17 @@ const requiredDevM3Docs = [
|
|||||||
"docs/dev-acceptance-matrix.md",
|
"docs/dev-acceptance-matrix.md",
|
||||||
"docs/m3-hardware-loop.md"
|
"docs/m3-hardware-loop.md"
|
||||||
];
|
];
|
||||||
|
const requiredDevEdgeIssue = "pikasTech/HWLAB#36";
|
||||||
|
const requiredDevEdgeValidationCommands = [
|
||||||
|
"node --check scripts/dev-edge-health-smoke.mjs",
|
||||||
|
"node --check scripts/src/dev-edge-health-smoke-lib.mjs",
|
||||||
|
"node scripts/dev-edge-health-smoke.mjs --live --write-report"
|
||||||
|
];
|
||||||
|
const requiredDevEdgeDocs = [
|
||||||
|
"docs/dev-acceptance-matrix.md",
|
||||||
|
"docs/m0-contract-audit.md",
|
||||||
|
"docs/dev-edge-health.md"
|
||||||
|
];
|
||||||
|
|
||||||
const statusValues = new Set(["pass", "blocked", "not_run", "not_applicable", "failed"]);
|
const statusValues = new Set(["pass", "blocked", "not_run", "not_applicable", "failed"]);
|
||||||
const blockerTypes = new Set([
|
const blockerTypes = new Set([
|
||||||
@@ -135,6 +146,10 @@ async function validateReport(relativePath) {
|
|||||||
await validateDevM3Report(report, label);
|
await validateDevM3Report(report, label);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (report.issue === requiredDevEdgeIssue || report.taskId === "dev-edge-health") {
|
||||||
|
await validateDevEdgeReport(report, label);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const field of [
|
for (const field of [
|
||||||
"$schema",
|
"$schema",
|
||||||
@@ -246,6 +261,116 @@ async function validateReport(relativePath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertDocumentSet(documentsValue, label, requiredDocs) {
|
||||||
|
assertStringArray(documentsValue, label, { minLength: requiredDocs.length });
|
||||||
|
const documents = new Set(documentsValue);
|
||||||
|
assertUnique(documentsValue, label);
|
||||||
|
for (const requiredDoc of requiredDocs) {
|
||||||
|
assert.ok(documents.has(requiredDoc), `${label} missing ${requiredDoc}`);
|
||||||
|
}
|
||||||
|
for (const [index, documentPath] of documentsValue.entries()) {
|
||||||
|
const absolutePath = assertRepoRelativePath(documentPath, `${label}[${index}]`);
|
||||||
|
await access(absolutePath, fsConstants.R_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertBlockers(blockers, label) {
|
||||||
|
assertArray(blockers, label);
|
||||||
|
assertUnique(
|
||||||
|
blockers.map((blocker) => `${blocker.type}::${blocker.scope}`),
|
||||||
|
label
|
||||||
|
);
|
||||||
|
for (const [index, blocker] of blockers.entries()) {
|
||||||
|
const blockerLabel = `${label}[${index}]`;
|
||||||
|
assertObject(blocker, blockerLabel);
|
||||||
|
for (const field of ["type", "scope", "status", "summary"]) {
|
||||||
|
assert.ok(Object.hasOwn(blocker, field), `${blockerLabel} missing ${field}`);
|
||||||
|
}
|
||||||
|
assert.ok(blockerTypes.has(blocker.type), `${blockerLabel}.type must be a known blocker type`);
|
||||||
|
assertString(blocker.scope, `${blockerLabel}.scope`);
|
||||||
|
assert.ok(blockerStates.has(blocker.status), `${blockerLabel}.status must be open, acknowledged, or closed`);
|
||||||
|
assertString(blocker.summary, `${blockerLabel}.summary`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateDevEdgeReport(report, label) {
|
||||||
|
for (const field of [
|
||||||
|
"$schema",
|
||||||
|
"$id",
|
||||||
|
"reportVersion",
|
||||||
|
"issue",
|
||||||
|
"taskId",
|
||||||
|
"commitId",
|
||||||
|
"acceptanceLevel",
|
||||||
|
"devOnly",
|
||||||
|
"prodDisabled",
|
||||||
|
"sourceContract",
|
||||||
|
"validationCommands",
|
||||||
|
"localSmoke",
|
||||||
|
"dryRun",
|
||||||
|
"devPreconditions",
|
||||||
|
"blockers",
|
||||||
|
"edgeHealth"
|
||||||
|
]) {
|
||||||
|
assert.ok(Object.hasOwn(report, field), `${label} missing ${field}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertString(report.$schema, `${label}.$schema`);
|
||||||
|
assertString(report.$id, `${label}.$id`);
|
||||||
|
assert.equal(report.reportVersion, "v1", `${label}.reportVersion`);
|
||||||
|
assert.equal(report.issue, requiredDevEdgeIssue, `${label}.issue`);
|
||||||
|
assert.equal(report.taskId, "dev-edge-health", `${label}.taskId`);
|
||||||
|
assert.match(report.commitId, /^([a-f0-9]{7,40}|unknown)$/, `${label}.commitId`);
|
||||||
|
assert.equal(report.acceptanceLevel, "dev_edge_health", `${label}.acceptanceLevel`);
|
||||||
|
assert.equal(report.devOnly, true, `${label}.devOnly`);
|
||||||
|
assert.equal(report.prodDisabled, true, `${label}.prodDisabled`);
|
||||||
|
|
||||||
|
assertObject(report.sourceContract, `${label}.sourceContract`);
|
||||||
|
assertStatus(report.sourceContract.status, `${label}.sourceContract.status`);
|
||||||
|
await assertDocumentSet(
|
||||||
|
report.sourceContract.documents,
|
||||||
|
`${label}.sourceContract.documents`,
|
||||||
|
requiredDevEdgeDocs
|
||||||
|
);
|
||||||
|
assertString(report.sourceContract.summary, `${label}.sourceContract.summary`);
|
||||||
|
|
||||||
|
assertStringArray(report.validationCommands, `${label}.validationCommands`, { minLength: 3 });
|
||||||
|
assertUnique(report.validationCommands, `${label}.validationCommands`);
|
||||||
|
for (const requiredCommand of requiredDevEdgeValidationCommands) {
|
||||||
|
assert.ok(
|
||||||
|
report.validationCommands.includes(requiredCommand),
|
||||||
|
`${label}.validationCommands missing ${requiredCommand}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const section of ["localSmoke", "dryRun", "devPreconditions"]) {
|
||||||
|
assertObject(report[section], `${label}.${section}`);
|
||||||
|
assertStatus(report[section].status, `${label}.${section}.status`);
|
||||||
|
assertString(report[section].summary, `${label}.${section}.summary`);
|
||||||
|
}
|
||||||
|
assertStringArray(report.localSmoke.commands, `${label}.localSmoke.commands`, { minLength: 1 });
|
||||||
|
assertStringArray(report.localSmoke.evidence, `${label}.localSmoke.evidence`, { minLength: 1 });
|
||||||
|
assertStringArray(report.dryRun.commands, `${label}.dryRun.commands`, { minLength: 1 });
|
||||||
|
assertStringArray(report.dryRun.evidence, `${label}.dryRun.evidence`, { minLength: 1 });
|
||||||
|
assertStringArray(report.devPreconditions.requirements, `${label}.devPreconditions.requirements`, {
|
||||||
|
minLength: 1
|
||||||
|
});
|
||||||
|
assertBlockers(report.blockers, `${label}.blockers`);
|
||||||
|
|
||||||
|
assertObject(report.edgeHealth, `${label}.edgeHealth`);
|
||||||
|
assertTimestamp(report.edgeHealth.generatedAt, `${label}.edgeHealth.generatedAt`);
|
||||||
|
assert.equal(report.edgeHealth.endpoint, "http://74.48.78.17:6667", `${label}.edgeHealth.endpoint`);
|
||||||
|
assert.ok(
|
||||||
|
["pass", "blocker", "not_run"].includes(report.edgeHealth.status),
|
||||||
|
`${label}.edgeHealth.status must be pass, blocker, or not_run`
|
||||||
|
);
|
||||||
|
assertString(report.edgeHealth.classification, `${label}.edgeHealth.classification`);
|
||||||
|
assertObject(report.edgeHealth.contracts, `${label}.edgeHealth.contracts`);
|
||||||
|
assertArray(report.edgeHealth.publicTcp ?? [], `${label}.edgeHealth.publicTcp`);
|
||||||
|
assertArray(report.edgeHealth.publicHttp ?? [], `${label}.edgeHealth.publicHttp`);
|
||||||
|
assertObject(report.edgeHealth.kubernetes ?? {}, `${label}.edgeHealth.kubernetes`);
|
||||||
|
}
|
||||||
|
|
||||||
async function validateDevM3Report(report, label) {
|
async function validateDevM3Report(report, label) {
|
||||||
for (const field of [
|
for (const field of [
|
||||||
"$schema",
|
"$schema",
|
||||||
|
|||||||
Reference in New Issue
Block a user