fix: migrate HWLAB v03 development public entry

This commit is contained in:
Codex
2026-07-14 20:32:11 +02:00
parent 2d063bc95c
commit bffe8dc87c
5 changed files with 166 additions and 27 deletions
+60 -3
View File
@@ -487,7 +487,7 @@ export interface HwlabRuntimePublicExposureFrpcExtraProxySpec extends HwlabRunti
readonly cloudWebEnvName?: string;
}
export interface HwlabRuntimePublicExposureSpec {
export interface HwlabRuntimePk01PublicExposureSpec {
readonly enabled: boolean;
readonly mode: "pk01-caddy-frp";
readonly publicBaseUrl: string;
@@ -511,6 +511,20 @@ export interface HwlabRuntimePublicExposureSpec {
readonly extraProxies: readonly HwlabRuntimePublicExposureFrpcExtraProxySpec[];
}
export interface HwlabRuntimeNodePortPublicExposureSpec {
readonly enabled: boolean;
readonly mode: "node-port-http";
readonly publicBaseUrl: string;
readonly publicAddress: string;
readonly serviceName: string;
readonly selector: Record<string, string>;
readonly port: number;
readonly targetPort: number;
readonly nodePort: number;
}
export type HwlabRuntimePublicExposureSpec = HwlabRuntimePk01PublicExposureSpec | HwlabRuntimeNodePortPublicExposureSpec;
export interface HwlabRuntimeBootstrapAdminSpec {
readonly username: string;
readonly displayName: string;
@@ -639,6 +653,7 @@ export interface HwlabRuntimePipelineProvenanceSpec {
export interface HwlabRuntimeLaneSpec {
readonly lane: HwlabRuntimeLane;
readonly environment?: "development" | "production";
readonly nodeId: string;
readonly nodeRoute: string;
readonly nodeKubeRoute: string;
@@ -711,6 +726,7 @@ export const HWLAB_NODE_LANE_CONFIG_PATH = "config/hwlab-node-lanes.yaml";
interface HwlabLaneConfig {
readonly id: HwlabRuntimeLane;
readonly environment?: "development" | "production";
readonly node: string;
readonly activeTarget?: string;
readonly minor: number;
@@ -956,6 +972,7 @@ function laneConfig(id: HwlabRuntimeLane, raw: Record<string, unknown>): HwlabLa
if (minor >= 3 && raw.sourceSnapshot === undefined) throw new Error(`lanes.${id}.sourceSnapshot is required for HWLAB runtime v0.3+`);
return {
id,
environment: raw.environment === undefined ? undefined : enumStringField(raw, "environment", `lanes.${id}`, ["development", "production"]),
node: stringField(raw, "node", `lanes.${id}`),
activeTarget: optionalStringField(raw, "activeTarget", `lanes.${id}`),
minor,
@@ -1952,7 +1969,27 @@ function publicExposureConfig(value: unknown, path: string): HwlabRuntimePublicE
if (value === undefined) return null;
const raw = asRecord(value, path);
const mode = stringField(raw, "mode", path);
if (mode !== "pk01-caddy-frp") throw new Error(`${path}.mode must be pk01-caddy-frp`);
const publicBaseUrl = stringField(raw, "publicBaseUrl", path).replace(/\/+$/u, "");
if (mode === "node-port-http") {
const service = asRecord(raw.service, `${path}.service`);
const selector = optionalStringRecord(service.selector, `${path}.service.selector`);
if (Object.keys(selector).length === 0) throw new Error(`${path}.service.selector must declare at least one label`);
const publicAddress = stringField(raw, "publicAddress", path);
const nodePort = numberField(service, "nodePort", `${path}.service`);
validateNodePortPublicExposureOrigin(publicBaseUrl, publicAddress, nodePort, `${path}.publicBaseUrl`);
return {
enabled: true,
mode,
publicBaseUrl,
publicAddress,
serviceName: stringField(service, "name", `${path}.service`),
selector,
port: numberField(service, "port", `${path}.service`),
targetPort: numberField(service, "targetPort", `${path}.service`),
nodePort,
};
}
if (mode !== "pk01-caddy-frp") throw new Error(`${path}.mode must be pk01-caddy-frp or node-port-http`);
const frpc = asRecord(raw.frpc, `${path}.frpc`);
const caddy = asRecord(raw.caddy, `${path}.caddy`);
const caddyTls = optionalStringField(caddy, "tls", `${path}.caddy`) ?? "auto";
@@ -1960,7 +1997,7 @@ function publicExposureConfig(value: unknown, path: string): HwlabRuntimePublicE
return {
enabled: true,
mode,
publicBaseUrl: stringField(raw, "publicBaseUrl", path).replace(/\/+$/u, ""),
publicBaseUrl,
hostname: stringField(raw, "hostname", path),
expectedA: stringField(raw, "expectedA", path),
serverAddr: stringField(frpc, "serverAddr", `${path}.frpc`),
@@ -1982,6 +2019,19 @@ function publicExposureConfig(value: unknown, path: string): HwlabRuntimePublicE
};
}
function validateNodePortPublicExposureOrigin(publicBaseUrl: string, publicAddress: string, nodePort: number, path: string): void {
try {
const parsed = new URL(publicBaseUrl);
if (parsed.protocol !== "http:") throw new Error("must use http");
if (parsed.hostname !== publicAddress) throw new Error(`hostname must match publicAddress ${publicAddress}`);
if (Number(parsed.port) !== nodePort) throw new Error(`port must match service.nodePort ${nodePort}`);
if (parsed.pathname !== "/" || parsed.search.length > 0 || parsed.hash.length > 0) throw new Error("must point to the public origin root");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`${path} must be an http NodePort origin root URL: ${message}`);
}
}
function validatePublicExposureOrigin(publicBaseUrl: string, path: string): void {
try {
const parsed = new URL(publicBaseUrl);
@@ -2297,8 +2347,15 @@ function buildRuntimeLaneSpec(config: HwlabLaneConfig): HwlabRuntimeLaneSpec {
const node = HWLAB_NODE_LANE_CONFIG.nodes[config.node];
const networkProfile = HWLAB_NODE_LANE_CONFIG.networkProfiles[node.networkProfileId];
const downloadProfile = HWLAB_NODE_LANE_CONFIG.downloadProfiles[node.downloadProfileId];
if (config.publicExposure?.mode === "node-port-http") {
if (config.environment === undefined) throw new Error(`${HWLAB_NODE_LANE_CONFIG_PATH}.lanes.${config.id}.environment is required for node-port-http public exposure`);
if (config.public.webUrl !== config.publicExposure.publicBaseUrl || config.public.apiUrl !== config.publicExposure.publicBaseUrl) {
throw new Error(`${HWLAB_NODE_LANE_CONFIG_PATH}.lanes.${config.id}.public webUrl/apiUrl must match publicExposure.publicBaseUrl for node-port-http`);
}
}
return {
lane: config.id,
...(config.environment === undefined ? {} : { environment: config.environment }),
nodeId: node.id,
nodeRoute: node.route,
nodeKubeRoute: node.kubeRoute,
+39 -8
View File
@@ -13,7 +13,7 @@ import { runCommand, type CommandResult } from "../command";
import { startJob } from "../jobs";
import { classifySshTcpPoolFailure } from "../ssh";
import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "../hwlab-node-control-plane";
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes";
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePk01PublicExposureSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes";
import { nodeWebProbeScriptRunnerSource } from "../hwlab-node-web-probe-runner-source";
import { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source";
import { nodeWebObserveRunnerSource } from "../hwlab-node-web-observe-runner-source";
@@ -440,6 +440,20 @@ export function runExternalPostgresSecretEnsure(options: NodeSecretOptions, spec
}
export function publicExposureSummary(exposure: HwlabRuntimePublicExposureSpec): Record<string, unknown> {
if (exposure.mode === "node-port-http") {
return {
mode: exposure.mode,
publicBaseUrl: exposure.publicBaseUrl,
publicAddress: exposure.publicAddress,
service: {
name: exposure.serviceName,
selector: exposure.selector,
port: exposure.port,
targetPort: exposure.targetPort,
nodePort: exposure.nodePort,
},
};
}
return {
mode: exposure.mode,
publicBaseUrl: exposure.publicBaseUrl,
@@ -480,6 +494,23 @@ export function runNodePublicExposure(options: NodePublicExposureOptions): Recor
mutation: false,
};
}
if (exposure.mode === "node-port-http") {
return {
ok: true,
command: "hwlab nodes control-plane public-exposure",
node: options.node,
lane: options.lane,
mode: "gitops-managed-node-port",
mutation: false,
configPath: hwlabRuntimeLaneConfigPath(),
publicExposure: publicExposureSummary(exposure),
valuesRedacted: true,
next: {
delivery: "merge the source PR and observe the automatic PaC/GitOps/Argo chain",
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${options.node} --lane ${options.lane}`,
},
};
}
const source = readPublicExposureTokenSource(exposure);
if (!source.ok) {
return {
@@ -531,7 +562,7 @@ export function runNodePublicExposure(options: NodePublicExposureOptions): Recor
};
}
function publicExposureSecretDryRunStatus(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec, tokenBytes: number): Record<string, unknown> {
function publicExposureSecretDryRunStatus(options: NodePublicExposureOptions, exposure: HwlabRuntimePk01PublicExposureSpec, tokenBytes: number): Record<string, unknown> {
return {
ok: true,
dryRun: true,
@@ -566,7 +597,7 @@ function publicExposureSecretDryRunStatus(options: NodePublicExposureOptions, ex
};
}
function publicExposureSecretApplyStatus(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec, token: string): Record<string, unknown> {
function publicExposureSecretApplyStatus(options: NodePublicExposureOptions, exposure: HwlabRuntimePk01PublicExposureSpec, token: string): Record<string, unknown> {
const secretApplyResult = runTransScript(options.node, publicExposureSecretScript(options, exposure), token, options.timeoutSeconds);
let secretFields = keyValueLinesFromText(statusText(secretApplyResult));
let secretResult = secretApplyResult;
@@ -578,7 +609,7 @@ function publicExposureSecretApplyStatus(options: NodePublicExposureOptions, exp
return publicExposureSecretStatus(secretFields, secretResult);
}
export function readPublicExposureTokenSource(exposure: HwlabRuntimePublicExposureSpec): { ok: boolean; path: string; checkedPaths: string[]; key: string; value: string | null; fingerprint: string | null; error?: string } {
export function readPublicExposureTokenSource(exposure: HwlabRuntimePk01PublicExposureSpec): { ok: boolean; path: string; checkedPaths: string[]; key: string; value: string | null; fingerprint: string | null; error?: string } {
const checkedPaths = publicExposureTokenSourcePaths(exposure);
const path = checkedPaths.find((candidate) => existsSync(candidate)) ?? checkedPaths[0] ?? rootPath(".state", "secrets", exposure.tokenSourceRef);
if (!existsSync(path)) return { ok: false, path, checkedPaths, key: exposure.tokenSourceKey, value: null, fingerprint: null, error: "secret-source-missing" };
@@ -595,7 +626,7 @@ export function readPublicExposureTokenSource(exposure: HwlabRuntimePublicExposu
};
}
export function publicExposureTokenSourcePaths(exposure: HwlabRuntimePublicExposureSpec): string[] {
export function publicExposureTokenSourcePaths(exposure: HwlabRuntimePk01PublicExposureSpec): string[] {
return [rootPath(".state", "secrets", exposure.tokenSourceRef)];
}
@@ -671,7 +702,7 @@ export function publicExposureCaddyStatus(fields: Record<string, string>, result
};
}
export function publicExposureSecretScript(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec): string {
export function publicExposureSecretScript(options: NodePublicExposureOptions, exposure: HwlabRuntimePk01PublicExposureSpec): string {
const deployment = `${options.spec.runtimeNamespace}-frpc`;
return [
"set +e",
@@ -859,7 +890,7 @@ export function combinePublicExposureCommandResults(first: CommandResult, second
};
}
export function publicExposureCaddyScript(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec): string {
export function publicExposureCaddyScript(options: NodePublicExposureOptions, exposure: HwlabRuntimePk01PublicExposureSpec): string {
const blockB64 = Buffer.from(publicExposureCaddyBlock(exposure), "utf8").toString("base64");
const marker = `unidesk managed ${exposure.hostname}`;
return [
@@ -976,7 +1007,7 @@ export function publicExposureCaddyScript(options: NodePublicExposureOptions, ex
].join("\n");
}
export function publicExposureCaddyBlock(exposure: HwlabRuntimePublicExposureSpec): string {
export function publicExposureCaddyBlock(exposure: HwlabRuntimePk01PublicExposureSpec): string {
const tlsLines = exposure.caddyTls === "internal" ? " tls internal\n" : "";
const mainBlock = `${exposure.hostname} {
${tlsLines} @api path /health* /v1* /json-rpc* /openapi* /docs* /swagger*
+42 -7
View File
@@ -1626,6 +1626,7 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s
" runtimePath: overlay.runtimePath,",
" runtimeRenderDir: overlay.runtimeRenderDir,",
" runtimeNamespace: overlay.runtimeNamespace,",
" environment: overlay.environment,",
" catalogPath: overlay.catalogPath,",
" gitUrl: overlay.gitUrl,",
" publicWebUrl: overlay.publicWebUrl,",
@@ -1723,6 +1724,7 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s
" runtimePath: overlay.runtimePath,",
" runtimeRenderDir: overlay.runtimeRenderDir,",
" runtimeNamespace: overlay.runtimeNamespace,",
" environment: overlay.environment,",
" externalPostgres: overlay.externalPostgres,",
" codeAgentRuntime: overlay.codeAgentRuntime,",
" publicExposure: overlay.publicExposure,",
@@ -2049,15 +2051,17 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s
" if (!fs.existsSync(file)) return false;",
" const doc = readYaml(file) || {};",
" const resources = Array.isArray(doc.resources) ? doc.resources : [];",
" const publicExposureEnabled = overlay.publicExposure && overlay.publicExposure.enabled;",
" const publicExposureMode = overlay.publicExposure && overlay.publicExposure.enabled ? overlay.publicExposure.mode : null;",
" const next = resources.filter((item) => {",
" const resource = String(item);",
" if (!publicExposureEnabled && resource === 'node-frpc.yaml') return false;",
" if (publicExposureMode !== 'pk01-caddy-frp' && resource === 'node-frpc.yaml') return false;",
" if (publicExposureMode !== 'node-port-http' && resource === 'node-public-service.yaml') return false;",
" if (overlay.observability && overlay.observability.prometheusOperator === false && resource === 'observability.yaml') return false;",
" return !(/\\.ya?ml$/u.test(resource) && !fs.existsSync(path.join(runtimePath, resource)));",
" });",
" let changed = false;",
" if (next.length !== resources.length) { doc.resources = next; writeYaml(file, doc); changed = true; }",
" if (publicExposureMode === 'node-port-http' && !next.includes('node-public-service.yaml')) next.push('node-public-service.yaml');",
" if (JSON.stringify(next) !== JSON.stringify(resources)) { doc.resources = next; writeYaml(file, doc); changed = true; }",
" const observabilityFile = path.join(runtimePath, 'observability.yaml');",
" if (overlay.observability && overlay.observability.prometheusOperator === false && fs.existsSync(observabilityFile)) { fs.rmSync(observabilityFile, { force: true }); changed = true; }",
" return changed;",
@@ -2171,12 +2175,31 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s
"}",
"function patchPublicExposure() {",
" const exposure = overlay.publicExposure;",
" const file = path.join(runtimePath, 'node-frpc.yaml');",
" const frpcFile = path.join(runtimePath, 'node-frpc.yaml');",
" const nodePortFile = path.join(runtimePath, 'node-public-service.yaml');",
" if (!exposure || !exposure.enabled) {",
" const changed = fs.existsSync(file);",
" if (changed) fs.rmSync(file, { force: true });",
" const changed = fs.existsSync(frpcFile) || fs.existsSync(nodePortFile);",
" fs.rmSync(frpcFile, { force: true });",
" fs.rmSync(nodePortFile, { force: true });",
" return { configured: false, changed };",
" }",
" if (exposure.mode === 'node-port-http') {",
" if (typeof overlay.environment !== 'string' || overlay.environment.length === 0) throw new Error('node-port-http public exposure requires owning YAML environment');",
" const service = {",
" apiVersion: 'v1',",
" kind: 'Service',",
" metadata: { name: String(exposure.serviceName), namespace: String(overlay.runtimeNamespace), labels: { 'app.kubernetes.io/name': String(exposure.serviceName), 'app.kubernetes.io/part-of': 'hwlab', 'hwlab.pikastech.local/environment': String(overlay.environment) } },",
" spec: { type: 'NodePort', selector: exposure.selector, ports: [{ name: 'http', protocol: 'TCP', port: Number(exposure.port), targetPort: Number(exposure.targetPort), nodePort: Number(exposure.nodePort) }] },",
" };",
" const rendered = YAML.stringify(service).trimEnd() + '\\n';",
" const before = fs.existsSync(nodePortFile) ? fs.readFileSync(nodePortFile, 'utf8') : '';",
" if (before !== rendered) fs.writeFileSync(nodePortFile, rendered);",
" const frpcRemoved = fs.existsSync(frpcFile);",
" fs.rmSync(frpcFile, { force: true });",
" return { configured: true, mode: exposure.mode, changed: before !== rendered || frpcRemoved, filePath: nodePortFile, serviceName: exposure.serviceName, nodePort: exposure.nodePort };",
" }",
" const file = frpcFile;",
" fs.rmSync(nodePortFile, { force: true });",
" if (!fs.existsSync(file)) {",
" console.error(JSON.stringify({ event: 'unidesk-public-exposure-postprocess', ok: false, reason: 'node-frpc-yaml-missing', filePath: file, hostname: exposure.hostname }));",
" process.exit(49);",
@@ -2238,6 +2261,7 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s
" const runtimeOverlay = JSON.stringify({",
" runtimePath: overlay.runtimePath,",
" runtimeNamespace: overlay.runtimeNamespace,",
" environment: overlay.environment,",
" externalPostgres: overlay.externalPostgres,",
" codeAgentRuntime: overlay.codeAgentRuntime,",
" publicExposure: overlay.publicExposure,",
@@ -2450,7 +2474,18 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s
" }",
"}",
"const exposure = overlay.publicExposure;",
"if (exposure && exposure.enabled) {",
"if (exposure && exposure.enabled && exposure.mode === 'node-port-http') {",
" const file = path.join(runtimePath, 'node-public-service.yaml');",
" if (!fs.existsSync(file)) fail('public-exposure-node-port-service-missing');",
" const service = allItemsFromFile(file).find((item) => item && item.kind === 'Service' && item.metadata && item.metadata.name === exposure.serviceName);",
" if (!service) fail('public-exposure-node-port-service-object-missing', { expected: exposure.serviceName });",
" const port = service.spec && Array.isArray(service.spec.ports) ? service.spec.ports.find((item) => item && Number(item.nodePort) === Number(exposure.nodePort)) : null;",
" if (!service.spec || service.spec.type !== 'NodePort') fail('public-exposure-node-port-service-type-mismatch', { expected: 'NodePort', actual: service.spec && service.spec.type });",
" if (!port || Number(port.port) !== Number(exposure.port) || Number(port.targetPort) !== Number(exposure.targetPort)) fail('public-exposure-node-port-service-port-mismatch', { expectedPort: exposure.port, expectedTargetPort: exposure.targetPort, expectedNodePort: exposure.nodePort });",
" if (JSON.stringify(service.spec.selector || {}) !== JSON.stringify(exposure.selector || {})) fail('public-exposure-node-port-service-selector-mismatch', { expected: exposure.selector, actual: service.spec.selector || {} });",
" checks.push('public-exposure-node-port');",
"}",
"if (exposure && exposure.enabled && exposure.mode === 'pk01-caddy-frp') {",
" const file = path.join(runtimePath, 'node-frpc.yaml');",
" if (!fs.existsSync(file)) fail('public-exposure-frpc-missing');",
" const items = allItemsFromFile(file);",
+12 -1
View File
@@ -78,6 +78,7 @@ export function nodeRuntimeRenderOverlay(spec: HwlabRuntimeLaneSpec): Record<str
pipelineProvenanceAnnotations: hwlabRuntimePipelineProvenanceAnnotations(spec),
nodeId: spec.nodeId,
lane: spec.lane,
environment: spec.environment,
sourceBranch: spec.sourceBranch,
gitopsBranch: spec.gitopsBranch,
gitopsRoot: nodeRuntimeGitopsRoot(spec),
@@ -139,7 +140,17 @@ export function nodeRuntimeRenderOverlay(spec: HwlabRuntimeLaneSpec): Record<str
buildkitSidecarImage: spec.buildkit?.sidecarImage,
publicWebUrl: spec.publicWebUrl,
publicApiUrl: spec.publicApiUrl,
publicExposure: spec.publicExposure === null ? undefined : {
publicExposure: spec.publicExposure === null ? undefined : spec.publicExposure.mode === "node-port-http" ? {
enabled: spec.publicExposure.enabled,
mode: spec.publicExposure.mode,
publicBaseUrl: spec.publicExposure.publicBaseUrl,
publicAddress: spec.publicExposure.publicAddress,
serviceName: spec.publicExposure.serviceName,
selector: spec.publicExposure.selector,
port: spec.publicExposure.port,
targetPort: spec.publicExposure.targetPort,
nodePort: spec.publicExposure.nodePort,
} : {
enabled: spec.publicExposure.enabled,
mode: spec.publicExposure.mode,
publicBaseUrl: spec.publicExposure.publicBaseUrl,