From a1c3cb58da0067a12424277d47d9d50d0cbf7a9d Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Fri, 22 May 2026 12:28:34 +0800 Subject: [PATCH] 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. --- deploy/README.md | 23 +- deploy/deploy.json | 145 ++++++++++++ deploy/deploy.schema.json | 257 ++++++++++++++++++-- deploy/frp/README.md | 6 +- deploy/master-edge/README.md | 9 +- package.json | 4 +- scripts/deploy-contract-plan.mjs | 7 + scripts/src/deploy-contract-plan.mjs | 335 +++++++++++++++++++++++++++ scripts/validate-contract.mjs | 16 +- 9 files changed, 776 insertions(+), 26 deletions(-) create mode 100644 scripts/deploy-contract-plan.mjs create mode 100644 scripts/src/deploy-contract-plan.mjs diff --git a/deploy/README.md b/deploy/README.md index 5f6debcb..715e1f8a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 diff --git a/deploy/deploy.json b/deploy/deploy.json index 8e5646af..fbe6cdd2 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -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", diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index f3255ba5..ad0a3891 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -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", diff --git a/deploy/frp/README.md b/deploy/frp/README.md index 2a2e1350..0930abdd 100644 --- a/deploy/frp/README.md +++ b/deploy/frp/README.md @@ -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 diff --git a/deploy/master-edge/README.md b/deploy/master-edge/README.md index f03e7d84..900629f9 100644 --- a/deploy/master-edge/README.md +++ b/deploy/master-edge/README.md @@ -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. diff --git a/package.json b/package.json index 97ec8f83..254267f9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/deploy-contract-plan.mjs b/scripts/deploy-contract-plan.mjs new file mode 100644 index 00000000..27bd5eb7 --- /dev/null +++ b/scripts/deploy-contract-plan.mjs @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { + runDeployContractPlanCli, + writeDeployContractPlanError +} from "./src/deploy-contract-plan.mjs"; + +runDeployContractPlanCli().catch(writeDeployContractPlanError); diff --git a/scripts/src/deploy-contract-plan.mjs b/scripts/src/deploy-contract-plan.mjs new file mode 100644 index 00000000..7677a17b --- /dev/null +++ b/scripts/src/deploy-contract-plan.mjs @@ -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; +} diff --git a/scripts/validate-contract.mjs b/scripts/validate-contract.mjs index 0d30c2b1..e8f61a14 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -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");