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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user