feat: add deploy contract dry-run plan
Refs #67 Merged by commander after reviewing Code Queue task codex_1779422774244_1. Adds deploy.json public endpoint/frp/k3s/health source fields and dry-run validation. Follow-up: align deploy.json commit/image desired state with the current DEV runtime before using it as apply truth.
This commit is contained in:
+19
-4
@@ -2,15 +2,18 @@
|
||||
|
||||
`deploy.schema.json` defines the future `deploy/deploy.json` manifest shape.
|
||||
|
||||
The MVP acceptance environment is DEV only, with endpoint
|
||||
`http://74.48.78.17:16667`. PROD profile fields may be represented in schema for
|
||||
future compatibility, but PROD deployment is not part of MVP acceptance.
|
||||
The MVP acceptance environment is DEV only. Public endpoint source fields live in
|
||||
`deploy/deploy.json`: frontend `http://74.48.78.17:16666` and API/edge
|
||||
`http://74.48.78.17:16667`. PROD profile fields may be represented in schema
|
||||
for future compatibility, but PROD deployment is not part of MVP acceptance.
|
||||
|
||||
## L5 DEV Skeleton
|
||||
|
||||
- `deploy/deploy.json` is the DEV-only deploy manifest.
|
||||
- `deploy/deploy.json` is the single source for `health.path`,
|
||||
`publicEndpoints`, `frp.proxies`, and `k3s.serviceMappings`.
|
||||
- `deploy/k8s/base` contains parseable k3s resource skeletons for the frozen
|
||||
HWLAB service IDs.
|
||||
HWLAB service IDs rendered from the manifest contract.
|
||||
- `deploy/k8s/dev` contains the DEV health contract and endpoint metadata.
|
||||
- `hwlab-cloud-api` declares the DEV DB env contract with
|
||||
`HWLAB_CLOUD_DB_URL` from Secret reference
|
||||
@@ -22,6 +25,18 @@ future compatibility, but PROD deployment is not part of MVP acceptance.
|
||||
- `deploy/frp` describes the D601-to-master reverse link without secrets.
|
||||
- `deploy/master-edge` describes public edge ownership and health boundaries.
|
||||
|
||||
Dry-run source rendering:
|
||||
|
||||
```sh
|
||||
node scripts/deploy-contract-plan.mjs --pretty
|
||||
node scripts/deploy-contract-plan.mjs --check
|
||||
```
|
||||
|
||||
The deploy contract plan renders and validates only. It does not run frps/frpc,
|
||||
does not call `kubectl apply`, does not restart services, and does not touch
|
||||
PROD. Later work can wire the same source fields into `hwlab edge apply` or
|
||||
`deploy apply`.
|
||||
|
||||
Next DEV deploy smoke commands, for a separately authorized deployment task:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -4,6 +4,151 @@
|
||||
"commitId": "73b379f",
|
||||
"namespace": "hwlab-dev",
|
||||
"endpoint": "http://74.48.78.17:16667",
|
||||
"health": {
|
||||
"method": "GET",
|
||||
"path": "/health/live",
|
||||
"responseFormat": "json"
|
||||
},
|
||||
"publicEndpoints": {
|
||||
"frontend": {
|
||||
"url": "http://74.48.78.17:16666",
|
||||
"protocol": "http",
|
||||
"host": "74.48.78.17",
|
||||
"port": 16666,
|
||||
"serviceId": "hwlab-cloud-web",
|
||||
"frpProxy": "hwlab-dev-cloud-web"
|
||||
},
|
||||
"api": {
|
||||
"url": "http://74.48.78.17:16667",
|
||||
"protocol": "http",
|
||||
"host": "74.48.78.17",
|
||||
"port": 16667,
|
||||
"serviceId": "hwlab-edge-proxy",
|
||||
"frpProxy": "hwlab-dev-edge-proxy"
|
||||
}
|
||||
},
|
||||
"frp": {
|
||||
"server": {
|
||||
"address": "74.48.78.17",
|
||||
"bindPort": 7000
|
||||
},
|
||||
"proxies": [
|
||||
{
|
||||
"name": "hwlab-dev-cloud-web",
|
||||
"endpointId": "frontend",
|
||||
"type": "tcp",
|
||||
"localServiceId": "hwlab-cloud-web",
|
||||
"localHost": "hwlab-cloud-web.hwlab-dev.svc.cluster.local",
|
||||
"localPort": 8080,
|
||||
"remotePort": 16666
|
||||
},
|
||||
{
|
||||
"name": "hwlab-dev-edge-proxy",
|
||||
"endpointId": "api",
|
||||
"type": "tcp",
|
||||
"localServiceId": "hwlab-edge-proxy",
|
||||
"localHost": "hwlab-edge-proxy.hwlab-dev.svc.cluster.local",
|
||||
"localPort": 6667,
|
||||
"remotePort": 16667
|
||||
},
|
||||
{
|
||||
"name": "hwlab-dev-tunnel-health",
|
||||
"type": "tcp",
|
||||
"localServiceId": "hwlab-tunnel-client",
|
||||
"localHost": "hwlab-tunnel-client.hwlab-dev.svc.cluster.local",
|
||||
"localPort": 7402,
|
||||
"remotePort": 7402
|
||||
}
|
||||
]
|
||||
},
|
||||
"k3s": {
|
||||
"serviceMappings": [
|
||||
{
|
||||
"serviceId": "hwlab-cloud-api",
|
||||
"name": "hwlab-cloud-api",
|
||||
"namespace": "hwlab-dev",
|
||||
"port": 6667,
|
||||
"targetPort": "http"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-cloud-web",
|
||||
"name": "hwlab-cloud-web",
|
||||
"namespace": "hwlab-dev",
|
||||
"port": 8080,
|
||||
"targetPort": "http"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-gateway",
|
||||
"name": "hwlab-gateway",
|
||||
"namespace": "hwlab-dev",
|
||||
"port": 7001,
|
||||
"targetPort": "http"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-cli",
|
||||
"name": "hwlab-cli",
|
||||
"namespace": "hwlab-dev",
|
||||
"port": 7501,
|
||||
"targetPort": "http"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-agent-skills",
|
||||
"name": "hwlab-agent-skills",
|
||||
"namespace": "hwlab-dev",
|
||||
"port": 7430,
|
||||
"targetPort": "http"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-agent-mgr",
|
||||
"name": "hwlab-agent-mgr",
|
||||
"namespace": "hwlab-dev",
|
||||
"port": 7410,
|
||||
"targetPort": "http"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-gateway-simu",
|
||||
"name": "hwlab-gateway-simu",
|
||||
"namespace": "hwlab-dev",
|
||||
"port": 7101,
|
||||
"targetPort": "http"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-box-simu",
|
||||
"name": "hwlab-box-simu",
|
||||
"namespace": "hwlab-dev",
|
||||
"port": 7201,
|
||||
"targetPort": "http"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-patch-panel",
|
||||
"name": "hwlab-patch-panel",
|
||||
"namespace": "hwlab-dev",
|
||||
"port": 7301,
|
||||
"targetPort": "http"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-router",
|
||||
"name": "hwlab-router",
|
||||
"namespace": "hwlab-dev",
|
||||
"port": 7401,
|
||||
"targetPort": "http"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-tunnel-client",
|
||||
"name": "hwlab-tunnel-client",
|
||||
"namespace": "hwlab-dev",
|
||||
"port": 7402,
|
||||
"targetPort": "http"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-edge-proxy",
|
||||
"name": "hwlab-edge-proxy",
|
||||
"namespace": "hwlab-dev",
|
||||
"port": 6667,
|
||||
"targetPort": "http"
|
||||
}
|
||||
]
|
||||
},
|
||||
"profiles": {
|
||||
"dev": {
|
||||
"name": "dev",
|
||||
|
||||
+240
-17
@@ -3,7 +3,7 @@
|
||||
"$id": "https://hwlab.pikastech.local/deploy/deploy.schema.json",
|
||||
"title": "HWLAB Deploy Manifest",
|
||||
"type": "object",
|
||||
"required": ["manifestVersion", "environment", "commitId", "profiles", "services"],
|
||||
"required": ["manifestVersion", "environment", "commitId", "health", "publicEndpoints", "frp", "k3s", "profiles", "services"],
|
||||
"properties": {
|
||||
"manifestVersion": {
|
||||
"type": "string",
|
||||
@@ -27,6 +27,18 @@
|
||||
"type": "string",
|
||||
"const": "http://74.48.78.17:16667"
|
||||
},
|
||||
"health": {
|
||||
"$ref": "#/$defs/health"
|
||||
},
|
||||
"publicEndpoints": {
|
||||
"$ref": "#/$defs/publicEndpoints"
|
||||
},
|
||||
"frp": {
|
||||
"$ref": "#/$defs/frp"
|
||||
},
|
||||
"k3s": {
|
||||
"$ref": "#/$defs/k3s"
|
||||
},
|
||||
"profiles": {
|
||||
"type": "object",
|
||||
"required": ["dev"],
|
||||
@@ -54,6 +66,232 @@
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"serviceId": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"hwlab-cloud-api",
|
||||
"hwlab-cloud-web",
|
||||
"hwlab-agent-mgr",
|
||||
"hwlab-agent-worker",
|
||||
"hwlab-gateway",
|
||||
"hwlab-gateway-simu",
|
||||
"hwlab-box-simu",
|
||||
"hwlab-patch-panel",
|
||||
"hwlab-router",
|
||||
"hwlab-tunnel-client",
|
||||
"hwlab-edge-proxy",
|
||||
"hwlab-cli",
|
||||
"hwlab-agent-skills"
|
||||
]
|
||||
},
|
||||
"health": {
|
||||
"type": "object",
|
||||
"required": ["method", "path", "responseFormat"],
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"const": "GET"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"const": "/health/live"
|
||||
},
|
||||
"responseFormat": {
|
||||
"type": "string",
|
||||
"const": "json"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"publicEndpoints": {
|
||||
"type": "object",
|
||||
"required": ["frontend", "api"],
|
||||
"properties": {
|
||||
"frontend": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/publicEndpoint"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"url": {
|
||||
"const": "http://74.48.78.17:16666"
|
||||
},
|
||||
"port": {
|
||||
"const": 16666
|
||||
},
|
||||
"serviceId": {
|
||||
"const": "hwlab-cloud-web"
|
||||
},
|
||||
"frpProxy": {
|
||||
"const": "hwlab-dev-cloud-web"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"api": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/publicEndpoint"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"url": {
|
||||
"const": "http://74.48.78.17:16667"
|
||||
},
|
||||
"port": {
|
||||
"const": 16667
|
||||
},
|
||||
"serviceId": {
|
||||
"const": "hwlab-edge-proxy"
|
||||
},
|
||||
"frpProxy": {
|
||||
"const": "hwlab-dev-edge-proxy"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"publicEndpoint": {
|
||||
"type": "object",
|
||||
"required": ["url", "protocol", "host", "port", "serviceId", "frpProxy"],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"pattern": "^http://74\\.48\\.78\\.17:1666[67]$"
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"const": "http"
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"const": "74.48.78.17"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"enum": [16666, 16667]
|
||||
},
|
||||
"serviceId": {
|
||||
"$ref": "#/$defs/serviceId"
|
||||
},
|
||||
"frpProxy": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"frp": {
|
||||
"type": "object",
|
||||
"required": ["server", "proxies"],
|
||||
"properties": {
|
||||
"server": {
|
||||
"type": "object",
|
||||
"required": ["address", "bindPort"],
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string",
|
||||
"const": "74.48.78.17"
|
||||
},
|
||||
"bindPort": {
|
||||
"type": "integer",
|
||||
"const": 7000
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"proxies": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/frpProxy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"frpProxy": {
|
||||
"type": "object",
|
||||
"required": ["name", "type", "localServiceId", "localHost", "localPort", "remotePort"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"endpointId": {
|
||||
"type": "string",
|
||||
"enum": ["frontend", "api"]
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "tcp"
|
||||
},
|
||||
"localServiceId": {
|
||||
"$ref": "#/$defs/serviceId"
|
||||
},
|
||||
"localHost": {
|
||||
"type": "string",
|
||||
"pattern": "^[-a-z0-9]+\\.hwlab-dev\\.svc\\.cluster\\.local$"
|
||||
},
|
||||
"localPort": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"remotePort": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"k3s": {
|
||||
"type": "object",
|
||||
"required": ["serviceMappings"],
|
||||
"properties": {
|
||||
"serviceMappings": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/k3sServiceMapping"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"k3sServiceMapping": {
|
||||
"type": "object",
|
||||
"required": ["serviceId", "name", "namespace", "port", "targetPort"],
|
||||
"properties": {
|
||||
"serviceId": {
|
||||
"$ref": "#/$defs/serviceId"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"namespace": {
|
||||
"type": "string",
|
||||
"const": "hwlab-dev"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"targetPort": {
|
||||
"type": "string",
|
||||
"const": "http"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"required": ["name", "enabled"],
|
||||
@@ -82,22 +320,7 @@
|
||||
"required": ["serviceId", "image", "namespace", "healthPath", "profile"],
|
||||
"properties": {
|
||||
"serviceId": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"hwlab-cloud-api",
|
||||
"hwlab-cloud-web",
|
||||
"hwlab-agent-mgr",
|
||||
"hwlab-agent-worker",
|
||||
"hwlab-gateway",
|
||||
"hwlab-gateway-simu",
|
||||
"hwlab-box-simu",
|
||||
"hwlab-patch-panel",
|
||||
"hwlab-router",
|
||||
"hwlab-tunnel-client",
|
||||
"hwlab-edge-proxy",
|
||||
"hwlab-cli",
|
||||
"hwlab-agent-skills"
|
||||
]
|
||||
"$ref": "#/$defs/serviceId"
|
||||
},
|
||||
"image": {
|
||||
"type": "string",
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
The DEV reverse link is D601 to master edge through frp.
|
||||
|
||||
- Public DEV endpoint: `http://74.48.78.17:16667`
|
||||
- Public API/edge endpoint: `http://74.48.78.17:16667`
|
||||
- Public browser endpoint: `http://74.48.78.17:16666`
|
||||
- `hwlab-tunnel-client` owns the frp client health contract.
|
||||
- `hwlab-cloud-web` owns the public browser entry on port `16666`.
|
||||
- `hwlab-edge-proxy` owns public API/health ingress on port `16667`.
|
||||
- `frps.dev.toml` reserves `16666` and `16667` only as TCP `remotePort` values; do not also bind
|
||||
them as `vhostHTTPPort`, because that collides with the DEV TCP proxy.
|
||||
- `deploy/deploy.json` is the source for the public endpoints and FRP proxy
|
||||
mapping. Run `node scripts/deploy-contract-plan.mjs --check` to detect drift
|
||||
such as public `6666`/`6667` reuse before any future apply command.
|
||||
- No secret values are stored in this repository. Operators must provide auth
|
||||
material out of band before a real deployment.
|
||||
- UniDesk backend, provider-gateway, and microservice proxy are not HWLAB runtime
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Master Edge DEV Contract
|
||||
|
||||
The master edge exposes only the DEV acceptance endpoint for this skeleton:
|
||||
The master edge exposes only the DEV acceptance endpoints for this skeleton:
|
||||
|
||||
```text
|
||||
http://74.48.78.17:16667
|
||||
frontend: http://74.48.78.17:16666
|
||||
api/edge: http://74.48.78.17:16667
|
||||
```
|
||||
|
||||
Health ownership:
|
||||
@@ -20,3 +21,7 @@ Health ownership:
|
||||
simulator and patch-panel health boundaries.
|
||||
|
||||
PROD is intentionally gated off for this task.
|
||||
|
||||
`deploy/deploy.json` is the source for the public endpoints, FRP proxy ports,
|
||||
k3s service mappings, and health path. The dry-run renderer validates these
|
||||
files only and does not perform a real edge apply or restart.
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"validate": "node scripts/validate-contract.mjs",
|
||||
"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",
|
||||
"dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
|
||||
"cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs",
|
||||
@@ -13,6 +13,8 @@
|
||||
"dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty",
|
||||
"dev:evidence:check": "node scripts/dev-evidence-blocker-aggregator.mjs --check",
|
||||
"dev-edge:smoke": "node scripts/dev-edge-health-smoke.mjs",
|
||||
"deploy:contract:plan": "node scripts/deploy-contract-plan.mjs --pretty",
|
||||
"deploy:contract:check": "node scripts/deploy-contract-plan.mjs --check",
|
||||
"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",
|
||||
"cli:projects": "node tools/hwlab-cli/bin/hwlab-cli.mjs project list",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import {
|
||||
runDeployContractPlanCli,
|
||||
writeDeployContractPlanError
|
||||
} from "./src/deploy-contract-plan.mjs";
|
||||
|
||||
runDeployContractPlanCli().catch(writeDeployContractPlanError);
|
||||
@@ -0,0 +1,335 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const expectedPorts = Object.freeze({ frontend: 16666, api: 16667 });
|
||||
const legacyPublicPorts = new Set([6666, 6667]);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { check: false, pretty: false };
|
||||
for (const arg of argv) {
|
||||
if (arg === "--check") {
|
||||
args.check = true;
|
||||
} else if (arg === "--pretty") {
|
||||
args.pretty = true;
|
||||
} else {
|
||||
throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function readJson(relativePath) {
|
||||
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
|
||||
}
|
||||
|
||||
async function parseToml(relativePath) {
|
||||
const code = [
|
||||
"import json,sys,tomllib",
|
||||
"with open(sys.argv[1], 'rb') as handle:",
|
||||
" print(json.dumps(tomllib.load(handle), sort_keys=True))"
|
||||
].join("\n");
|
||||
const result = await execFileAsync("python3", ["-c", code, path.join(repoRoot, relativePath)]);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
function addDiagnostic(ctx, code, jsonPath, message, { expected, actual } = {}) {
|
||||
ctx.diagnostics.push({
|
||||
code,
|
||||
path: jsonPath,
|
||||
message,
|
||||
...(expected === undefined ? {} : { expected }),
|
||||
...(actual === undefined ? {} : { actual })
|
||||
});
|
||||
}
|
||||
|
||||
function expect(ctx, condition, code, jsonPath, message, details) {
|
||||
ctx.checks += 1;
|
||||
if (!condition) {
|
||||
addDiagnostic(ctx, code, jsonPath, message, details);
|
||||
}
|
||||
}
|
||||
|
||||
function expectEqual(ctx, actual, expected, jsonPath, message) {
|
||||
expect(ctx, Object.is(actual, expected), "contract_mismatch", jsonPath, message, { expected, actual });
|
||||
}
|
||||
|
||||
function flagLegacyPublicPort(ctx, jsonPath, actual, expected, label) {
|
||||
if (legacyPublicPorts.has(Number(actual))) {
|
||||
addDiagnostic(ctx, "legacy_public_endpoint_drift", jsonPath, `${label} uses legacy public port ${actual}`, {
|
||||
expected,
|
||||
actual
|
||||
});
|
||||
return;
|
||||
}
|
||||
expectEqual(ctx, Number(actual), expected, jsonPath, `${label} public port`);
|
||||
}
|
||||
|
||||
function listItems(doc) {
|
||||
return doc?.kind === "List" ? doc.items ?? [] : [doc].filter(Boolean);
|
||||
}
|
||||
|
||||
function mapByServiceId(items) {
|
||||
return new Map(
|
||||
items.map((item) => [
|
||||
item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ?? item?.metadata?.name,
|
||||
item
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function parseEndpoint(ctx, id, endpoint) {
|
||||
const expectedPort = expectedPorts[id];
|
||||
expect(ctx, Boolean(endpoint), "missing_source", `$.publicEndpoints.${id}`, `${id} public endpoint is required`);
|
||||
if (!endpoint) return null;
|
||||
|
||||
let urlPort = null;
|
||||
try {
|
||||
const url = new URL(endpoint.url);
|
||||
urlPort = Number(url.port || (url.protocol === "http:" ? 80 : 443));
|
||||
expectEqual(ctx, url.protocol.replace(":", ""), endpoint.protocol, `$.publicEndpoints.${id}.url`, `${id} URL protocol`);
|
||||
expectEqual(ctx, url.hostname, endpoint.host, `$.publicEndpoints.${id}.url`, `${id} URL host`);
|
||||
} catch (error) {
|
||||
addDiagnostic(ctx, "invalid_url", `$.publicEndpoints.${id}.url`, `${id} public endpoint URL is invalid`, {
|
||||
actual: endpoint.url
|
||||
});
|
||||
}
|
||||
|
||||
expectEqual(ctx, endpoint.protocol, "http", `$.publicEndpoints.${id}.protocol`, `${id} public endpoint protocol`);
|
||||
expectEqual(ctx, endpoint.host, "74.48.78.17", `$.publicEndpoints.${id}.host`, `${id} public endpoint host`);
|
||||
flagLegacyPublicPort(ctx, `$.publicEndpoints.${id}.port`, endpoint.port, expectedPort, `${id} endpoint`);
|
||||
if (urlPort !== null) {
|
||||
flagLegacyPublicPort(ctx, `$.publicEndpoints.${id}.url`, urlPort, expectedPort, `${id} endpoint URL`);
|
||||
}
|
||||
return { endpointId: id, ...endpoint };
|
||||
}
|
||||
|
||||
function validateSource(ctx, manifest) {
|
||||
expectEqual(ctx, manifest.environment, "dev", "$.environment", "manifest environment");
|
||||
expectEqual(ctx, manifest.profiles?.prod?.enabled, false, "$.profiles.prod.enabled", "PROD profile must stay disabled");
|
||||
expectEqual(ctx, manifest.health?.method, "GET", "$.health.method", "health method");
|
||||
expectEqual(ctx, manifest.health?.path, "/health/live", "$.health.path", "health path");
|
||||
expectEqual(ctx, manifest.health?.responseFormat, "json", "$.health.responseFormat", "health response format");
|
||||
|
||||
const services = manifest.services ?? [];
|
||||
const servicesById = new Map(services.map((service) => [service.serviceId, service]));
|
||||
for (const service of services) {
|
||||
expectEqual(ctx, service.healthPath, manifest.health?.path, `$.services.${service.serviceId}.healthPath`, `${service.serviceId} health path`);
|
||||
}
|
||||
|
||||
const endpoints = {
|
||||
frontend: parseEndpoint(ctx, "frontend", manifest.publicEndpoints?.frontend),
|
||||
api: parseEndpoint(ctx, "api", manifest.publicEndpoints?.api)
|
||||
};
|
||||
for (const [id, endpoint] of Object.entries(endpoints)) {
|
||||
expect(ctx, servicesById.has(endpoint?.serviceId), "unknown_service", `$.publicEndpoints.${id}.serviceId`, `${id} public endpoint service must exist`);
|
||||
}
|
||||
expectEqual(ctx, manifest.endpoint, endpoints.api?.url, "$.endpoint", "legacy manifest endpoint alias must point to API endpoint");
|
||||
expectEqual(ctx, manifest.profiles?.dev?.endpoint, endpoints.api?.url, "$.profiles.dev.endpoint", "DEV profile endpoint");
|
||||
|
||||
const cloudApi = servicesById.get("hwlab-cloud-api");
|
||||
const tunnelClient = servicesById.get("hwlab-tunnel-client");
|
||||
const cli = servicesById.get("hwlab-cli");
|
||||
expectEqual(ctx, cloudApi?.env?.HWLAB_PUBLIC_ENDPOINT, endpoints.api?.url, "$.services.hwlab-cloud-api.env.HWLAB_PUBLIC_ENDPOINT", "cloud API public endpoint env");
|
||||
expectEqual(ctx, cli?.env?.HWLAB_CLI_ENDPOINT, endpoints.api?.url, "$.services.hwlab-cli.env.HWLAB_CLI_ENDPOINT", "CLI endpoint env");
|
||||
expectEqual(ctx, tunnelClient?.env?.HWLAB_FRP_PUBLIC_PORT, String(expectedPorts.api), "$.services.hwlab-tunnel-client.env.HWLAB_FRP_PUBLIC_PORT", "tunnel API public port env");
|
||||
expectEqual(ctx, tunnelClient?.env?.HWLAB_FRP_WEB_PUBLIC_PORT, String(expectedPorts.frontend), "$.services.hwlab-tunnel-client.env.HWLAB_FRP_WEB_PUBLIC_PORT", "tunnel frontend public port env");
|
||||
|
||||
const proxies = manifest.frp?.proxies ?? [];
|
||||
const proxyNames = new Set();
|
||||
for (const [index, proxy] of proxies.entries()) {
|
||||
const basePath = `$.frp.proxies[${index}]`;
|
||||
expect(ctx, !proxyNames.has(proxy.name), "duplicate_source", `${basePath}.name`, `duplicate frp proxy ${proxy.name}`);
|
||||
proxyNames.add(proxy.name);
|
||||
expectEqual(ctx, proxy.type, "tcp", `${basePath}.type`, `${proxy.name} proxy type`);
|
||||
expectEqual(ctx, proxy.localHost, `${proxy.localServiceId}.${manifest.namespace}.svc.cluster.local`, `${basePath}.localHost`, `${proxy.name} local host`);
|
||||
if (proxy.endpointId) {
|
||||
const endpoint = endpoints[proxy.endpointId];
|
||||
expectEqual(ctx, endpoint?.frpProxy, proxy.name, `${basePath}.name`, `${proxy.endpointId} endpoint frp proxy`);
|
||||
flagLegacyPublicPort(ctx, `${basePath}.remotePort`, proxy.remotePort, endpoint?.port, `${proxy.name} remote`);
|
||||
}
|
||||
}
|
||||
|
||||
const serviceMappings = manifest.k3s?.serviceMappings ?? [];
|
||||
const mappingIds = new Set();
|
||||
for (const [index, mapping] of serviceMappings.entries()) {
|
||||
const basePath = `$.k3s.serviceMappings[${index}]`;
|
||||
expect(ctx, servicesById.has(mapping.serviceId), "unknown_service", `${basePath}.serviceId`, `${mapping.serviceId} must exist in services`);
|
||||
expect(ctx, !mappingIds.has(mapping.serviceId), "duplicate_source", `${basePath}.serviceId`, `duplicate k3s mapping ${mapping.serviceId}`);
|
||||
mappingIds.add(mapping.serviceId);
|
||||
expectEqual(ctx, mapping.namespace, manifest.namespace, `${basePath}.namespace`, `${mapping.serviceId} namespace`);
|
||||
expectEqual(ctx, mapping.targetPort, "http", `${basePath}.targetPort`, `${mapping.serviceId} targetPort`);
|
||||
}
|
||||
|
||||
return { manifest, endpoints, servicesById, proxies, serviceMappings, healthPath: manifest.health?.path };
|
||||
}
|
||||
|
||||
function renderPlan(source) {
|
||||
const proxyPorts = [...new Set(source.proxies.map((proxy) => Number(proxy.remotePort)))].sort((a, b) => a - b);
|
||||
return {
|
||||
publicEndpoints: Object.fromEntries(
|
||||
Object.entries(source.endpoints).map(([id, endpoint]) => [id, {
|
||||
url: endpoint?.url,
|
||||
serviceId: endpoint?.serviceId,
|
||||
port: endpoint?.port,
|
||||
frpProxy: endpoint?.frpProxy
|
||||
}])
|
||||
),
|
||||
frpc: {
|
||||
serverAddr: source.manifest.frp?.server?.address,
|
||||
serverPort: source.manifest.frp?.server?.bindPort,
|
||||
proxies: source.proxies.map((proxy) => ({
|
||||
name: proxy.name,
|
||||
type: proxy.type,
|
||||
localIP: proxy.localHost,
|
||||
localPort: proxy.localPort,
|
||||
remotePort: proxy.remotePort
|
||||
}))
|
||||
},
|
||||
frps: {
|
||||
bindPort: source.manifest.frp?.server?.bindPort,
|
||||
allowPorts: proxyPorts.map((port) => ({ start: port, end: port }))
|
||||
},
|
||||
k3sServices: source.serviceMappings.map((mapping) => ({
|
||||
name: mapping.name,
|
||||
namespace: mapping.namespace,
|
||||
serviceId: mapping.serviceId,
|
||||
port: mapping.port,
|
||||
targetPort: mapping.targetPort
|
||||
})),
|
||||
health: {
|
||||
method: source.manifest.health?.method,
|
||||
path: source.healthPath,
|
||||
endpoint: source.endpoints.api?.url
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function validateFrpArtifacts(ctx, source, rendered, frpc, frps) {
|
||||
expectEqual(ctx, frpc.serverAddr, rendered.frpc.serverAddr, "deploy/frp/frpc.dev.toml.serverAddr", "frpc server address");
|
||||
expectEqual(ctx, frpc.serverPort, rendered.frpc.serverPort, "deploy/frp/frpc.dev.toml.serverPort", "frpc server port");
|
||||
const actualProxies = new Map((frpc.proxies ?? []).map((proxy) => [proxy.name, proxy]));
|
||||
for (const proxy of source.proxies) {
|
||||
const actual = actualProxies.get(proxy.name);
|
||||
expect(ctx, Boolean(actual), "missing_rendered_artifact", `deploy/frp/frpc.dev.toml.${proxy.name}`, `${proxy.name} proxy exists`);
|
||||
if (!actual) continue;
|
||||
expectEqual(ctx, actual.type, proxy.type, `deploy/frp/frpc.dev.toml.${proxy.name}.type`, `${proxy.name} type`);
|
||||
expectEqual(ctx, actual.localIP, proxy.localHost, `deploy/frp/frpc.dev.toml.${proxy.name}.localIP`, `${proxy.name} local IP`);
|
||||
expectEqual(ctx, actual.localPort, proxy.localPort, `deploy/frp/frpc.dev.toml.${proxy.name}.localPort`, `${proxy.name} local port`);
|
||||
if (proxy.endpointId) {
|
||||
flagLegacyPublicPort(ctx, `deploy/frp/frpc.dev.toml.${proxy.name}.remotePort`, actual.remotePort, source.endpoints[proxy.endpointId]?.port, `${proxy.name} rendered remote`);
|
||||
} else {
|
||||
expectEqual(ctx, actual.remotePort, proxy.remotePort, `deploy/frp/frpc.dev.toml.${proxy.name}.remotePort`, `${proxy.name} remote port`);
|
||||
}
|
||||
}
|
||||
|
||||
expectEqual(ctx, frps.bindPort, rendered.frps.bindPort, "deploy/frp/frps.dev.toml.bindPort", "frps bind port");
|
||||
for (const allow of rendered.frps.allowPorts) {
|
||||
const match = (frps.allowPorts ?? []).some((actual) => actual.start === allow.start && actual.end === allow.end);
|
||||
expect(ctx, match, "missing_rendered_artifact", `deploy/frp/frps.dev.toml.allowPorts.${allow.start}`, `frps allows remote port ${allow.start}`);
|
||||
}
|
||||
for (const [index, allow] of (frps.allowPorts ?? []).entries()) {
|
||||
for (const legacyPort of legacyPublicPorts) {
|
||||
if (allow.start <= legacyPort && allow.end >= legacyPort) {
|
||||
addDiagnostic(ctx, "legacy_public_endpoint_drift", `deploy/frp/frps.dev.toml.allowPorts[${index}]`, `frps allows legacy public port ${legacyPort}`, {
|
||||
expected: [expectedPorts.frontend, expectedPorts.api],
|
||||
actual: { start: allow.start, end: allow.end }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateK3sArtifacts(ctx, source, rendered, services, workloads, healthContract, masterEdge) {
|
||||
const serviceItems = mapByServiceId(listItems(services));
|
||||
const workloadItems = mapByServiceId(listItems(workloads));
|
||||
for (const expected of rendered.k3sServices) {
|
||||
const actual = serviceItems.get(expected.serviceId);
|
||||
expect(ctx, Boolean(actual), "missing_rendered_artifact", `deploy/k8s/base/services.yaml.${expected.serviceId}`, `${expected.serviceId} k3s service exists`);
|
||||
if (actual) {
|
||||
const port = (actual.spec?.ports ?? []).find((entry) => entry.name === "http") ?? actual.spec?.ports?.[0];
|
||||
expectEqual(ctx, actual.metadata?.name, expected.name, `deploy/k8s/base/services.yaml.${expected.serviceId}.metadata.name`, `${expected.serviceId} service name`);
|
||||
expectEqual(ctx, actual.metadata?.namespace, expected.namespace, `deploy/k8s/base/services.yaml.${expected.serviceId}.metadata.namespace`, `${expected.serviceId} service namespace`);
|
||||
expectEqual(ctx, port?.port, expected.port, `deploy/k8s/base/services.yaml.${expected.serviceId}.spec.ports.http.port`, `${expected.serviceId} service port`);
|
||||
expectEqual(ctx, port?.targetPort, expected.targetPort, `deploy/k8s/base/services.yaml.${expected.serviceId}.spec.ports.http.targetPort`, `${expected.serviceId} service targetPort`);
|
||||
}
|
||||
const container = workloadItems.get(expected.serviceId)?.spec?.template?.spec?.containers?.[0];
|
||||
for (const probeName of ["readinessProbe", "livenessProbe"]) {
|
||||
if (container?.[probeName]?.httpGet?.path) {
|
||||
expectEqual(ctx, container[probeName].httpGet.path, source.healthPath, `deploy/k8s/base/workloads.yaml.${expected.serviceId}.${probeName}`, `${expected.serviceId} ${probeName} path`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expectEqual(ctx, healthContract.data?.endpoint, source.endpoints.api?.url, "deploy/k8s/dev/health-contract.yaml.data.endpoint", "health contract endpoint");
|
||||
expectEqual(ctx, masterEdge.endpoint, source.endpoints.api?.url, "deploy/master-edge/health-contract.json.endpoint", "master edge endpoint");
|
||||
flagLegacyPublicPort(ctx, "deploy/master-edge/health-contract.json.reverseLink.publicPort", masterEdge.reverseLink?.publicPort, expectedPorts.api, "master edge API");
|
||||
flagLegacyPublicPort(ctx, "deploy/master-edge/health-contract.json.reverseLink.webPublicPort", masterEdge.reverseLink?.webPublicPort, expectedPorts.frontend, "master edge frontend");
|
||||
expectEqual(ctx, masterEdge.prodAcceptance, false, "deploy/master-edge/health-contract.json.prodAcceptance", "master edge PROD acceptance");
|
||||
}
|
||||
|
||||
async function buildPlan() {
|
||||
const ctx = { checks: 0, diagnostics: [] };
|
||||
const [manifest, services, workloads, healthContract, masterEdge, frpc, frps] = await Promise.all([
|
||||
readJson("deploy/deploy.json"),
|
||||
readJson("deploy/k8s/base/services.yaml"),
|
||||
readJson("deploy/k8s/base/workloads.yaml"),
|
||||
readJson("deploy/k8s/dev/health-contract.yaml"),
|
||||
readJson("deploy/master-edge/health-contract.json"),
|
||||
parseToml("deploy/frp/frpc.dev.toml"),
|
||||
parseToml("deploy/frp/frps.dev.toml")
|
||||
]);
|
||||
const source = validateSource(ctx, manifest);
|
||||
const rendered = renderPlan(source);
|
||||
validateFrpArtifacts(ctx, source, rendered, frpc, frps);
|
||||
validateK3sArtifacts(ctx, source, rendered, services, workloads, healthContract, masterEdge);
|
||||
const drift = ctx.diagnostics.filter((item) => item.code === "legacy_public_endpoint_drift");
|
||||
return {
|
||||
kind: "hwlab-deploy-contract-plan",
|
||||
mode: "dry-run",
|
||||
status: ctx.diagnostics.length === 0 ? "pass" : "blocked",
|
||||
source: "deploy/deploy.json",
|
||||
dryRunOnly: true,
|
||||
safety: {
|
||||
frpApply: false,
|
||||
kubectlApply: false,
|
||||
prod: false,
|
||||
restart: false
|
||||
},
|
||||
summary: {
|
||||
publicEndpoints: Object.keys(rendered.publicEndpoints).length,
|
||||
frpProxies: rendered.frpc.proxies.length,
|
||||
k3sServices: rendered.k3sServices.length,
|
||||
healthPath: rendered.health.path,
|
||||
legacyPublicEndpointDrift: drift.length,
|
||||
checks: ctx.checks
|
||||
},
|
||||
rendered,
|
||||
diagnostics: ctx.diagnostics,
|
||||
nextIntegration: ["hwlab edge apply", "deploy apply"]
|
||||
};
|
||||
}
|
||||
|
||||
export async function runDeployContractPlanCli(argv = process.argv.slice(2)) {
|
||||
const args = parseArgs(argv);
|
||||
const plan = await buildPlan();
|
||||
process.stdout.write(`${JSON.stringify(plan, null, args.pretty ? 2 : 0)}\n`);
|
||||
if (args.check && plan.status !== "pass") {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeDeployContractPlanError(error) {
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
kind: "hwlab-deploy-contract-plan",
|
||||
mode: "dry-run",
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
safety: { frpApply: false, kubectlApply: false, prod: false, restart: false }
|
||||
})}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -64,9 +64,14 @@ function assertCommonSchema(commonSchema) {
|
||||
}
|
||||
|
||||
function assertDeploySchema(deploySchema) {
|
||||
const serviceIds = deploySchema.$defs.service.properties.serviceId.enum;
|
||||
const serviceIds = deploySchema.$defs.serviceId.enum;
|
||||
assert.deepEqual(serviceIds, SERVICE_IDS, "deploy service ids must match runtime constants");
|
||||
assert.equal(deploySchema.properties.endpoint.const, "http://74.48.78.17:16667");
|
||||
assert.ok(deploySchema.required.includes("health"), "deploy schema requires health source");
|
||||
assert.ok(deploySchema.required.includes("publicEndpoints"), "deploy schema requires public endpoint source");
|
||||
assert.ok(deploySchema.required.includes("frp"), "deploy schema requires frp source");
|
||||
assert.ok(deploySchema.required.includes("k3s"), "deploy schema requires k3s source");
|
||||
assert.equal(deploySchema.$defs.health.properties.path.const, "/health/live", "deploy health path source");
|
||||
}
|
||||
|
||||
function assertEnvelopeValidation() {
|
||||
@@ -132,6 +137,15 @@ 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(deployManifest.health.path, "/health/live", "deploy health source path");
|
||||
assert.equal(deployManifest.publicEndpoints.frontend.url, "http://74.48.78.17:16666", "deploy frontend endpoint");
|
||||
assert.equal(deployManifest.publicEndpoints.api.url, "http://74.48.78.17:16667", "deploy api endpoint");
|
||||
assert.equal(deployManifest.frp.proxies.find((proxy) => proxy.endpointId === "frontend")?.remotePort, 16666, "deploy frontend frp remote port");
|
||||
assert.equal(deployManifest.frp.proxies.find((proxy) => proxy.endpointId === "api")?.remotePort, 16667, "deploy api frp remote port");
|
||||
assert.ok(
|
||||
deployManifest.k3s.serviceMappings.some((mapping) => mapping.serviceId === "hwlab-edge-proxy" && mapping.port === 6667),
|
||||
"deploy edge proxy k3s mapping"
|
||||
);
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user