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:
Lyon
2026-05-22 12:28:34 +08:00
committed by GitHub
parent 357a68bb95
commit a1c3cb58da
9 changed files with 776 additions and 26 deletions
+19 -4
View File
@@ -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
+145
View File
@@ -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
View File
@@ -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",
+5 -1
View File
@@ -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
+7 -2
View File
@@ -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
View File
@@ -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",
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env node
import {
runDeployContractPlanCli,
writeDeployContractPlanError
} from "./src/deploy-contract-plan.mjs";
runDeployContractPlanCli().catch(writeDeployContractPlanError);
+335
View File
@@ -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;
}
+15 -1
View File
@@ -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");