feat: declare D601 v03 public exposure

This commit is contained in:
Codex
2026-06-12 23:44:38 +00:00
parent 1ea1fe1c48
commit 71c5d1168c
3 changed files with 592 additions and 4 deletions
+32 -2
View File
@@ -133,8 +133,38 @@ lanes:
- source: fatedier/frpc:v0.68.1
target: 127.0.0.1:5000/hwlab/frpc:v0.68.1
public:
webUrl: http://74.48.78.17:20666
apiUrl: http://74.48.78.17:20667
webUrl: https://v03.hwpod.com
apiUrl: https://v03.hwpod.com
publicExposure:
mode: pk01-caddy-frp
publicBaseUrl: https://v03.hwpod.com
hostname: v03.hwpod.com
expectedA: 82.156.23.220
frpc:
serverAddr: 82.156.23.220
serverPort: 22000
tokenSourceRef: platform-infra/pk01-frp.env
tokenSourceKey: FRP_TOKEN
secretName: hwlab-v03-frpc-secrets
secretKey: frpc.toml
tokenKey: token
webProxy:
name: hwlab-d601-v03-cloud-web
remotePort: 22096
localIP: hwlab-cloud-web.hwlab-v03.svc.cluster.local
localPort: 8080
apiProxy:
name: hwlab-d601-v03-edge-proxy
remotePort: 22095
localIP: hwlab-edge-proxy.hwlab-v03.svc.cluster.local
localPort: 6667
caddy:
route: PK01
configPath: /etc/caddy/Caddyfile
serviceName: caddy
email: ops@pikapython.com
tls: internal
responseHeaderTimeoutSeconds: 600
externalPostgres:
provider: PK01
configRef: config/platform-db/postgres-pk01.yaml
+78
View File
@@ -90,6 +90,36 @@ export interface HwlabRuntimeImageRewriteSpec {
readonly target: string;
}
export interface HwlabRuntimePublicExposureFrpcProxySpec {
readonly name: string;
readonly localIP: string;
readonly localPort: number;
readonly remotePort: number;
}
export interface HwlabRuntimePublicExposureSpec {
readonly enabled: boolean;
readonly mode: "pk01-caddy-frp";
readonly publicBaseUrl: string;
readonly hostname: string;
readonly expectedA: string;
readonly serverAddr: string;
readonly serverPort: number;
readonly tokenSourceRef: string;
readonly tokenSourceKey: string;
readonly secretName: string;
readonly secretKey: string;
readonly tokenKey: string;
readonly caddyRoute: string;
readonly caddyConfigPath: string;
readonly caddyServiceName: string;
readonly caddyEmail: string;
readonly caddyTls: "auto" | "internal";
readonly responseHeaderTimeoutSeconds: number;
readonly webProxy: HwlabRuntimePublicExposureFrpcProxySpec;
readonly apiProxy: HwlabRuntimePublicExposureFrpcProxySpec;
}
export interface HwlabRuntimeLaneSpec {
readonly lane: HwlabRuntimeLane;
readonly nodeId: string;
@@ -127,6 +157,7 @@ export interface HwlabRuntimeLaneSpec {
readonly stepEnv: Record<string, string>;
readonly buildkit?: HwlabRuntimeBuildkitSpec;
readonly externalPostgres?: HwlabRuntimeExternalPostgresSpec;
readonly publicExposure: HwlabRuntimePublicExposureSpec | null;
readonly observability: HwlabRuntimeObservabilitySpec;
readonly runtimeImageRewrites: readonly HwlabRuntimeImageRewriteSpec[];
readonly networkProfileId: string;
@@ -166,6 +197,7 @@ interface HwlabLaneConfig {
readonly stepEnv: Record<string, string>;
readonly buildkit?: HwlabRuntimeBuildkitSpec;
readonly externalPostgres?: HwlabRuntimeExternalPostgresSpec;
readonly publicExposure: HwlabRuntimePublicExposureSpec | null;
readonly observability: HwlabRuntimeObservabilitySpec;
readonly runtimeImageRewrites: readonly HwlabRuntimeImageRewriteSpec[];
}
@@ -365,6 +397,7 @@ function laneConfig(id: HwlabRuntimeLane, raw: Record<string, unknown>): HwlabLa
stepEnv: optionalStringRecord(raw.stepEnv, `lanes.${id}.stepEnv`),
buildkit: buildkitConfig(raw.buildkit, `lanes.${id}.buildkit`),
externalPostgres: externalPostgresConfig(raw.externalPostgres, `lanes.${id}.externalPostgres`),
publicExposure: publicExposureConfig(raw.publicExposure, `lanes.${id}.publicExposure`),
observability: observabilityConfig(raw.observability, `lanes.${id}.observability`),
runtimeImageRewrites: runtimeImageRewritesConfig(raw.runtimeImageRewrites, `lanes.${id}.runtimeImageRewrites`),
};
@@ -382,6 +415,7 @@ function laneTargetConfig(id: HwlabRuntimeLane, nodeId: string, baseRaw: Record<
stepEnv: mergeOptionalRecord(baseRaw.stepEnv, targetRaw.stepEnv) ?? {},
buildkit: mergeOptionalRecord(baseRaw.buildkit, targetRaw.buildkit),
externalPostgres: mergeOptionalRecord(baseRaw.externalPostgres, targetRaw.externalPostgres),
publicExposure: mergeOptionalRecord(baseRaw.publicExposure, targetRaw.publicExposure),
observability: mergeOptionalRecord(baseRaw.observability, targetRaw.observability),
};
delete merged.targets;
@@ -427,6 +461,49 @@ function externalPostgresConfig(value: unknown, path: string): HwlabRuntimeExter
};
}
function publicExposureProxyConfig(value: unknown, path: string): HwlabRuntimePublicExposureFrpcProxySpec {
const raw = asRecord(value, path);
return {
name: stringField(raw, "name", path),
localIP: stringField(raw, "localIP", path),
localPort: numberField(raw, "localPort", path),
remotePort: numberField(raw, "remotePort", path),
};
}
function publicExposureConfig(value: unknown, path: string): HwlabRuntimePublicExposureSpec | null {
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 frpc = asRecord(raw.frpc, `${path}.frpc`);
const caddy = asRecord(raw.caddy, `${path}.caddy`);
const caddyTls = optionalStringField(caddy, "tls", `${path}.caddy`) ?? "auto";
if (caddyTls !== "auto" && caddyTls !== "internal") throw new Error(`${path}.caddy.tls must be auto or internal`);
return {
enabled: true,
mode,
publicBaseUrl: stringField(raw, "publicBaseUrl", path).replace(/\/+$/u, ""),
hostname: stringField(raw, "hostname", path),
expectedA: stringField(raw, "expectedA", path),
serverAddr: stringField(frpc, "serverAddr", `${path}.frpc`),
serverPort: numberField(frpc, "serverPort", `${path}.frpc`),
tokenSourceRef: stringField(frpc, "tokenSourceRef", `${path}.frpc`),
tokenSourceKey: stringField(frpc, "tokenSourceKey", `${path}.frpc`),
secretName: stringField(frpc, "secretName", `${path}.frpc`),
secretKey: optionalStringField(frpc, "secretKey", `${path}.frpc`) ?? "frpc.toml",
tokenKey: optionalStringField(frpc, "tokenKey", `${path}.frpc`) ?? "token",
caddyRoute: stringField(caddy, "route", `${path}.caddy`),
caddyConfigPath: stringField(caddy, "configPath", `${path}.caddy`),
caddyServiceName: stringField(caddy, "serviceName", `${path}.caddy`),
caddyEmail: stringField(caddy, "email", `${path}.caddy`),
caddyTls,
responseHeaderTimeoutSeconds: numberField(caddy, "responseHeaderTimeoutSeconds", `${path}.caddy`),
webProxy: publicExposureProxyConfig(frpc.webProxy, `${path}.frpc.webProxy`),
apiProxy: publicExposureProxyConfig(frpc.apiProxy, `${path}.frpc.apiProxy`),
};
}
function observabilityConfig(value: unknown, path: string): HwlabRuntimeObservabilitySpec {
const raw = asRecord(value, path);
return {
@@ -527,6 +604,7 @@ function buildRuntimeLaneSpec(config: HwlabLaneConfig): HwlabRuntimeLaneSpec {
stepEnv: config.stepEnv,
...(config.buildkit === undefined ? {} : { buildkit: config.buildkit }),
...(config.externalPostgres === undefined ? {} : { externalPostgres: config.externalPostgres }),
publicExposure: config.publicExposure,
observability: config.observability,
runtimeImageRewrites: config.runtimeImageRewrites,
networkProfileId: networkProfile.id,
+482 -2
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { repoRoot, rootPath, type Config } from "./config";
@@ -5,7 +6,7 @@ import { runCommand, type CommandResult } from "./command";
import { startJob } from "./jobs";
import { runHwlabG14Command } from "./hwlab-g14";
import { hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "./hwlab-node-control-plane";
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimePublicExposureSpec } from "./hwlab-node-lanes";
type SecretAction = "status" | "ensure" | "cleanup-owned-postgres" | "cleanup-obsolete";
type SecretPreset = "openfga" | "master-server-admin-api-key" | "bootstrap-admin" | "code-agent-provider" | "cloud-api-db" | "owned-postgres-cleanup" | "obsolete-secret-cleanup";
@@ -31,6 +32,16 @@ interface NodeSecretOptions {
timeoutSeconds: number;
}
interface NodePublicExposureOptions {
action: "public-exposure";
node: string;
lane: HwlabRuntimeLane;
dryRun: boolean;
confirm: boolean;
timeoutSeconds: number;
spec: HwlabRuntimeLaneSpec;
}
interface RuntimeSecretSpec {
node: string;
lane: string;
@@ -116,6 +127,8 @@ export function hwlabNodeHelp(): Record<string, unknown> {
"bun scripts/cli.ts hwlab nodes control-plane apply --node G14 --lane v03 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane refresh --node G14 --lane v03 --confirm",
"bun scripts/cli.ts hwlab nodes control-plane sync --node D601 --lane v03 --confirm",
"bun scripts/cli.ts hwlab nodes control-plane public-exposure --node D601 --lane v03 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane public-exposure --node D601 --lane v03 --confirm",
"bun scripts/cli.ts hwlab nodes control-plane trigger-current --node G14 --lane v03 --confirm",
"bun scripts/cli.ts hwlab nodes control-plane runtime-migration --node D601 --lane v03 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane runtime-migration --node D601 --lane v03 --confirm",
@@ -139,6 +152,17 @@ export function hwlabNodeHelp(): Record<string, unknown> {
async function runNodeDelegatedDomain(config: Config, domain: DelegatedNodeDomain, args: string[]): Promise<Record<string, unknown>> {
const scoped = parseNodeScopedDelegatedOptions(domain, args);
const defaultSpec = hwlabRuntimeLaneSpec(scoped.lane);
if (domain === "control-plane" && scoped.action === "public-exposure") {
return runNodePublicExposure({
action: "public-exposure",
node: scoped.node,
lane: scoped.lane,
dryRun: scoped.dryRun || !scoped.confirm,
confirm: scoped.confirm,
timeoutSeconds: scoped.timeoutSeconds,
spec: scoped.spec,
});
}
if (domain === "control-plane" && scoped.action === "plan") {
return nodeRuntimeControlPlanePlan(scoped);
}
@@ -262,6 +286,7 @@ function nodeRuntimeExpected(spec: HwlabRuntimeLaneSpec): Record<string, unknown
webUrl: spec.publicWebUrl,
apiUrl: spec.publicApiUrl,
},
publicExposure: spec.publicExposure === null ? null : publicExposureSummary(spec.publicExposure),
downloadProfile: {
id: spec.downloadProfileId,
git: spec.downloadProfile.git,
@@ -317,6 +342,7 @@ function nodeRuntimeControlPlanePlan(scoped: ReturnType<typeof parseNodeScopedDe
secretValuesPrinted: false,
runtimeNamespace: scoped.spec.runtimeNamespace,
localPostgresExpectedAbsent: scoped.spec.externalPostgres !== undefined,
publicExposureDeclared: scoped.spec.publicExposure !== null,
},
next: {
infraStatus: `bun scripts/cli.ts hwlab nodes control-plane infra status --node ${scoped.node} --lane ${scoped.lane}`,
@@ -324,6 +350,7 @@ function nodeRuntimeControlPlanePlan(scoped: ReturnType<typeof parseNodeScopedDe
platformDbStatus: scoped.spec.externalPostgres === undefined
? null
: `bun scripts/cli.ts platform-db postgres status --config ${scoped.spec.externalPostgres.configRef}`,
publicExposure: scoped.spec.publicExposure === null ? null : `bun scripts/cli.ts hwlab nodes control-plane public-exposure --node ${scoped.node} --lane ${scoped.lane} --confirm`,
},
};
}
@@ -1519,6 +1546,7 @@ function nodeRuntimePipelinePostprocessScript(): string[] {
" runtimeRenderDir: overlay.runtimeRenderDir,",
" runtimeNamespace: overlay.runtimeNamespace,",
" externalPostgres: overlay.externalPostgres,",
" publicExposure: overlay.publicExposure,",
" observability: overlay.observability,",
" runtimeImageRewrites: overlay.runtimeImageRewrites,",
" gitReadUrl: overlay.gitReadUrl,",
@@ -1528,6 +1556,7 @@ function nodeRuntimePipelinePostprocessScript(): string[] {
" return `node - <<'NODE_UNIDESK_RUNTIME_GITOPS_POSTPROCESS'",
"const fs = require('fs');",
"const path = require('path');",
"const crypto = require('crypto');",
"const YAML = require('yaml');",
"const overlay = ${runtimeOverlay};",
"const runtimePath = String(overlay.runtimePath || '');",
@@ -1818,11 +1847,90 @@ function nodeRuntimePipelinePostprocessScript(): string[] {
" if (changed) writeYaml(file, normalizeList(items));",
" return changed;",
"}",
"function renderPublicExposureFrpcToml(exposure) {",
" return [",
" 'serverAddr = ' + JSON.stringify(String(exposure.serverAddr)),",
" 'serverPort = ' + Number(exposure.serverPort),",
" 'loginFailExit = true',",
" 'auth.token = \"{{ .Envs.HWLAB_FRP_TOKEN }}\"',",
" '',",
" '[[proxies]]',",
" 'name = ' + JSON.stringify(String(exposure.webProxy.name)),",
" 'type = \"tcp\"',",
" 'localIP = ' + JSON.stringify(String(exposure.webProxy.localIP)),",
" 'localPort = ' + Number(exposure.webProxy.localPort),",
" 'remotePort = ' + Number(exposure.webProxy.remotePort),",
" '',",
" '[[proxies]]',",
" 'name = ' + JSON.stringify(String(exposure.apiProxy.name)),",
" 'type = \"tcp\"',",
" 'localIP = ' + JSON.stringify(String(exposure.apiProxy.localIP)),",
" 'localPort = ' + Number(exposure.apiProxy.localPort),",
" 'remotePort = ' + Number(exposure.apiProxy.remotePort),",
" '',",
" ].join('\\\\n');",
"}",
"function setEnvFromSecret(container, name, secretName, secretKey) {",
" if (!isObject(container)) return false;",
" container.env = Array.isArray(container.env) ? container.env : [];",
" let item = container.env.find((env) => env && env.name === name);",
" if (!item) { item = { name }; container.env.push(item); }",
" const nextValueFrom = { secretKeyRef: { name: secretName, key: secretKey } };",
" const changed = item.value !== undefined || JSON.stringify(item.valueFrom || {}) !== JSON.stringify(nextValueFrom);",
" delete item.value;",
" item.valueFrom = nextValueFrom;",
" return changed;",
"}",
"function patchPublicExposure() {",
" const exposure = overlay.publicExposure;",
" if (!exposure || !exposure.enabled) return { configured: false, changed: false };",
" const file = path.join(runtimePath, 'node-frpc.yaml');",
" 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);",
" }",
" const docs = readYamlDocuments(file);",
" const configName = String(overlay.runtimeNamespace) + '-frpc-config';",
" const deploymentName = String(overlay.runtimeNamespace) + '-frpc';",
" const configKey = String(exposure.secretKey || 'frpc.toml');",
" const tokenKey = String(exposure.tokenKey || 'token');",
" const toml = renderPublicExposureFrpcToml(exposure);",
" let changed = false;",
" let foundConfigMap = false;",
" let foundDeployment = false;",
" for (const doc of docs) {",
" for (const item of listItems(doc).filter(Boolean)) {",
" if (!isObject(item)) continue;",
" item.metadata = item.metadata || {};",
" if (item.kind === 'ConfigMap' && item.metadata.name === configName) {",
" foundConfigMap = true;",
" item.data = item.data || {};",
" if (item.data[configKey] !== toml) { item.data[configKey] = toml; changed = true; }",
" }",
" if (item.kind === 'Deployment' && item.metadata.name === deploymentName) {",
" foundDeployment = true;",
" const podSpec = podSpecFor(item);",
" for (const container of Array.isArray(podSpec && podSpec.containers) ? podSpec.containers : []) {",
" if (!isObject(container)) continue;",
" if (container.name === 'frpc' || String(container.image || '').includes('frpc')) changed = setEnvFromSecret(container, 'HWLAB_FRP_TOKEN', exposure.secretName, tokenKey) || changed;",
" }",
" }",
" }",
" }",
" if (!foundConfigMap || !foundDeployment) {",
" console.error(JSON.stringify({ event: 'unidesk-public-exposure-postprocess', ok: false, reason: 'frpc-resource-missing', filePath: file, configName, deploymentName, foundConfigMap, foundDeployment }));",
" process.exit(50);",
" }",
" if (changed) writeYamlDocuments(file, docs);",
" console.error(JSON.stringify({ event: 'unidesk-public-exposure-postprocess', ok: true, applied: true, changed, filePath: file, hostname: exposure.hostname, serverAddr: exposure.serverAddr, serverPort: exposure.serverPort, webProxy: exposure.webProxy.name, apiProxy: exposure.apiProxy.name, configSha256: crypto.createHash('sha256').update(toml).digest('hex') }));",
" return { configured: true, changed, foundConfigMap, foundDeployment };",
"}",
"const kustomizationChanged = patchKustomization();",
"const runtimeWorkloadsChanged = patchRuntimeWorkloads();",
"const externalPostgresChanged = patchExternalPostgres();",
"const healthContractChanged = patchHealthContract();",
"console.error(JSON.stringify({ event: 'unidesk-runtime-gitops-postprocess', ok: true, runtimePath, sourcePath, pathRelocated: sourcePath !== runtimePath, observabilityPrometheusOperator: overlay.observability ? overlay.observability.prometheusOperator : null, runtimeImageRewriteCount: (overlay.runtimeImageRewrites || []).length, kustomizationChanged, observabilityWorkloadsChanged: runtimeWorkloadsChanged.observabilityChanged, startupProbeChanged: runtimeWorkloadsChanged.startupProbeChanged, runtimeImageRewriteChanged: runtimeWorkloadsChanged.imageRewriteChanged, gitReadUrlChanged: runtimeWorkloadsChanged.gitReadUrlChanged, publicEndpointChanged: runtimeWorkloadsChanged.publicEndpointChanged, dbSslModeChanged: runtimeWorkloadsChanged.dbSslModeChanged, externalPostgresChanged, healthContractChanged }));",
"const publicExposureChanged = patchPublicExposure();",
"console.error(JSON.stringify({ event: 'unidesk-runtime-gitops-postprocess', ok: true, runtimePath, sourcePath, pathRelocated: sourcePath !== runtimePath, observabilityPrometheusOperator: overlay.observability ? overlay.observability.prometheusOperator : null, runtimeImageRewriteCount: (overlay.runtimeImageRewrites || []).length, kustomizationChanged, observabilityWorkloadsChanged: runtimeWorkloadsChanged.observabilityChanged, startupProbeChanged: runtimeWorkloadsChanged.startupProbeChanged, runtimeImageRewriteChanged: runtimeWorkloadsChanged.imageRewriteChanged, gitReadUrlChanged: runtimeWorkloadsChanged.gitReadUrlChanged, publicEndpointChanged: runtimeWorkloadsChanged.publicEndpointChanged, dbSslModeChanged: runtimeWorkloadsChanged.dbSslModeChanged, externalPostgresChanged, healthContractChanged, publicExposureChanged }));",
"NODE_UNIDESK_RUNTIME_GITOPS_POSTPROCESS`;",
"}",
"function runtimeGitopsVerifyScript() {",
@@ -1830,6 +1938,7 @@ function nodeRuntimePipelinePostprocessScript(): string[] {
" runtimePath: overlay.runtimePath,",
" runtimeNamespace: overlay.runtimeNamespace,",
" externalPostgres: overlay.externalPostgres,",
" publicExposure: overlay.publicExposure,",
" observability: overlay.observability,",
" runtimeImageRewrites: overlay.runtimeImageRewrites,",
" gitReadUrl: overlay.gitReadUrl,",
@@ -1860,6 +1969,7 @@ function nodeRuntimePipelinePostprocessScript(): string[] {
" return files;",
"}",
"function readYamlDocuments(file) { return YAML.parseAllDocuments(fs.readFileSync(file, 'utf8')).map((document) => document.toJS()).filter((doc) => doc !== null); }",
"function allItemsFromFile(file) { return readYamlDocuments(file).flatMap((doc) => listItems(doc).filter(Boolean)); }",
"function podSpecFor(item) {",
" if (!isObject(item) || !isObject(item.spec)) return null;",
" if (item.kind === 'Pod') return item.spec;",
@@ -1944,6 +2054,32 @@ function nodeRuntimePipelinePostprocessScript(): string[] {
" if (String(endpointAddress) !== String(pg.endpointAddress)) fail('external-postgres-address-mismatch', { endpointAddress, expectedEndpointAddress: pg.endpointAddress });",
" checks.push('external-postgres-bridge');",
"}",
"const exposure = overlay.publicExposure;",
"if (exposure && exposure.enabled) {",
" const file = path.join(runtimePath, 'node-frpc.yaml');",
" if (!fs.existsSync(file)) fail('public-exposure-frpc-missing');",
" const items = allItemsFromFile(file);",
" const configName = String(overlay.runtimeNamespace) + '-frpc-config';",
" const deploymentName = String(overlay.runtimeNamespace) + '-frpc';",
" const configKey = String(exposure.secretKey || 'frpc.toml');",
" const tokenKey = String(exposure.tokenKey || 'token');",
" const configMap = items.find((item) => item && item.kind === 'ConfigMap' && item.metadata && item.metadata.name === configName);",
" const deployment = items.find((item) => item && item.kind === 'Deployment' && item.metadata && item.metadata.name === deploymentName);",
" if (!configMap) fail('public-exposure-frpc-configmap-missing', { expected: configName });",
" if (!deployment) fail('public-exposure-frpc-deployment-missing', { expected: deploymentName });",
" const toml = configMap.data && configMap.data[configKey];",
" if (typeof toml !== 'string') fail('public-exposure-frpc-config-missing', { expectedKey: configKey });",
" for (const expected of [String(exposure.serverAddr), String(exposure.serverPort), String(exposure.webProxy.name), String(exposure.webProxy.remotePort), String(exposure.apiProxy.name), String(exposure.apiProxy.remotePort), 'HWLAB_FRP_TOKEN']) {",
" if (!toml.includes(expected)) fail('public-exposure-frpc-config-mismatch', { expected });",
" }",
" const podSpec = podSpecFor(deployment);",
" const containers = Array.isArray(podSpec && podSpec.containers) ? podSpec.containers : [];",
" const frpc = containers.find((container) => container && (container.name === 'frpc' || String(container.image || '').includes('frpc')));",
" const env = frpc && Array.isArray(frpc.env) ? frpc.env.find((item) => item && item.name === 'HWLAB_FRP_TOKEN') : null;",
" const secretRef = env && env.valueFrom && env.valueFrom.secretKeyRef;",
" if (!secretRef || secretRef.name !== exposure.secretName || secretRef.key !== tokenKey) fail('public-exposure-frpc-token-env-mismatch', { expectedSecret: exposure.secretName, expectedKey: tokenKey });",
" checks.push('public-exposure-frpc');",
"}",
"console.error(JSON.stringify({ event: 'unidesk-runtime-gitops-verify', ok: true, runtimePath, checks }));",
"NODE_UNIDESK_RUNTIME_GITOPS_VERIFY`;",
"}",
@@ -2176,6 +2312,20 @@ function nodeRuntimeRenderOverlay(spec: HwlabRuntimeLaneSpec): Record<string, un
buildkitSidecarImage: spec.buildkit?.sidecarImage,
publicWebUrl: spec.publicWebUrl,
publicApiUrl: spec.publicApiUrl,
publicExposure: spec.publicExposure === null ? undefined : {
enabled: spec.publicExposure.enabled,
mode: spec.publicExposure.mode,
publicBaseUrl: spec.publicExposure.publicBaseUrl,
hostname: spec.publicExposure.hostname,
expectedA: spec.publicExposure.expectedA,
serverAddr: spec.publicExposure.serverAddr,
serverPort: spec.publicExposure.serverPort,
secretName: spec.publicExposure.secretName,
secretKey: spec.publicExposure.secretKey,
tokenKey: spec.publicExposure.tokenKey,
webProxy: spec.publicExposure.webProxy,
apiProxy: spec.publicExposure.apiProxy,
},
externalPostgres: spec.externalPostgres === undefined ? undefined : {
enabled: true,
serviceName: spec.externalPostgres.serviceName,
@@ -2938,6 +3088,336 @@ function nextSecretCommand(options: NodeSecretOptions, spec: RuntimeSecretSpec):
return { ensure: `bun scripts/cli.ts hwlab nodes secret ensure --node ${options.node} --lane ${options.lane} --name ${options.name}${options.key ? ` --key ${options.key}` : ""} --confirm` };
}
function publicExposureSummary(exposure: HwlabRuntimePublicExposureSpec): Record<string, unknown> {
return {
mode: exposure.mode,
publicBaseUrl: exposure.publicBaseUrl,
hostname: exposure.hostname,
expectedA: exposure.expectedA,
frpc: {
serverAddr: exposure.serverAddr,
serverPort: exposure.serverPort,
tokenSourceRef: exposure.tokenSourceRef,
tokenSourceKey: exposure.tokenSourceKey,
secretName: exposure.secretName,
secretKey: exposure.secretKey,
tokenKey: exposure.tokenKey,
webProxy: exposure.webProxy,
apiProxy: exposure.apiProxy,
},
caddy: {
route: exposure.caddyRoute,
configPath: exposure.caddyConfigPath,
serviceName: exposure.caddyServiceName,
tls: exposure.caddyTls,
responseHeaderTimeoutSeconds: exposure.responseHeaderTimeoutSeconds,
},
};
}
function runNodePublicExposure(options: NodePublicExposureOptions): Record<string, unknown> {
const exposure = options.spec.publicExposure;
if (exposure === null || !exposure.enabled) {
return {
ok: false,
command: "hwlab nodes control-plane public-exposure",
node: options.node,
lane: options.lane,
configPath: hwlabRuntimeLaneConfigPath(),
error: "publicExposure is not configured for this node/lane target",
mutation: false,
};
}
const source = readPublicExposureTokenSource(exposure);
if (!source.ok) {
return {
ok: false,
command: "hwlab nodes control-plane public-exposure",
node: options.node,
lane: options.lane,
mode: options.dryRun ? "dry-run" : "confirmed",
configPath: hwlabRuntimeLaneConfigPath(),
publicExposure: publicExposureSummary(exposure),
source,
mutation: false,
valuesRedacted: true,
next: { fixSecretSource: `create .state/secrets/${exposure.tokenSourceRef} with ${exposure.tokenSourceKey}=<redacted>` },
};
}
const secretResult = runTransScript(options.node, publicExposureSecretScript(options, exposure), source.value ?? "", options.timeoutSeconds);
const secretFields = keyValueLinesFromText(statusText(secretResult));
const caddyResult = runTransHostScript(exposure.caddyRoute, publicExposureCaddyScript(options, exposure), "", options.timeoutSeconds);
const caddyFields = keyValueLinesFromText(statusText(caddyResult));
const secretOk = options.dryRun ? secretResult.exitCode === 0 : secretResult.exitCode === 0 && secretFields.afterSecretExists === "yes";
const ok = secretOk
&& caddyResult.exitCode === 0
&& caddyFields.afterBlockPresent === "yes"
&& caddyFields.validateExitCode === "0"
&& (options.dryRun || caddyFields.active === "active");
return {
ok,
command: "hwlab nodes control-plane public-exposure",
node: options.node,
lane: options.lane,
mode: options.dryRun ? "dry-run" : "confirmed",
mutation: !options.dryRun && (secretFields.mutation === "true" || caddyFields.mutation === "true"),
configPath: hwlabRuntimeLaneConfigPath(),
publicExposure: publicExposureSummary(exposure),
source: {
ok: source.ok,
path: source.path,
key: exposure.tokenSourceKey,
bytes: source.value?.length ?? 0,
fingerprint: source.fingerprint,
valueRedacted: true,
},
secret: publicExposureSecretStatus(secretFields, secretResult),
caddy: publicExposureCaddyStatus(caddyFields, caddyResult),
valuesRedacted: true,
next: ok
? {
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${options.node} --lane ${options.lane} --confirm`,
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${options.node} --lane ${options.lane}`,
}
: { retry: `bun scripts/cli.ts hwlab nodes control-plane public-exposure --node ${options.node} --lane ${options.lane} --confirm` },
};
}
function readPublicExposureTokenSource(exposure: HwlabRuntimePublicExposureSpec): { 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] ?? join(repoRoot, ".state", "secrets", exposure.tokenSourceRef);
if (!existsSync(path)) return { ok: false, path, checkedPaths, key: exposure.tokenSourceKey, value: null, fingerprint: null, error: "secret-source-missing" };
const values = parseEnvFile(readFileSync(path, "utf8"));
const value = values[exposure.tokenSourceKey];
if (value === undefined || value.length === 0) return { ok: false, path, checkedPaths, key: exposure.tokenSourceKey, value: null, fingerprint: null, error: "secret-key-missing" };
return {
ok: true,
path,
checkedPaths,
key: exposure.tokenSourceKey,
value,
fingerprint: `sha256:${createHash("sha256").update(value).digest("hex").slice(0, 16)}`,
};
}
function publicExposureTokenSourcePaths(exposure: HwlabRuntimePublicExposureSpec): string[] {
const paths = [join(repoRoot, ".state", "secrets", exposure.tokenSourceRef)];
const marker = "/.worktree/";
const index = repoRoot.indexOf(marker);
if (index >= 0) paths.push(join(repoRoot.slice(0, index), ".state", "secrets", exposure.tokenSourceRef));
return [...new Set(paths)];
}
function publicExposureSecretStatus(fields: Record<string, string>, result: CommandResult): Record<string, unknown> {
const dryRun = fields.dryRun === "true";
return {
ok: result.exitCode === 0 && (dryRun || fields.afterSecretExists === "yes"),
dryRun,
mutation: fields.mutation === "true",
namespace: fields.namespace || null,
secret: fields.secret || null,
beforeExists: fields.beforeSecretExists === "yes",
afterExists: fields.afterSecretExists === "yes",
tokenBytes: numericField(fields.tokenBytes),
applyExitCode: numericField(fields.applyExitCode),
rolloutRestartExitCode: numericField(fields.rolloutRestartExitCode),
rolloutStatusExitCode: numericField(fields.rolloutStatusExitCode),
exitCode: result.exitCode,
stderr: result.exitCode === 0 ? "" : result.stderr.trim().slice(0, 2000),
};
}
function publicExposureCaddyStatus(fields: Record<string, string>, result: CommandResult): Record<string, unknown> {
return {
ok: result.exitCode === 0 && fields.afterBlockPresent === "yes" && fields.validateExitCode === "0",
dryRun: fields.dryRun === "true",
mutation: fields.mutation === "true",
hostname: fields.hostname || null,
configPath: fields.configPath || null,
beforeBlockPresent: fields.beforeBlockPresent === "yes",
afterBlockPresent: fields.afterBlockPresent === "yes",
validateMode: fields.validateMode || null,
validateExitCode: numericField(fields.validateExitCode),
reloadExitCode: numericField(fields.reloadExitCode),
active: fields.active || null,
localWebStatus: fields.localWebStatus || null,
localApiStatus: fields.localApiStatus || null,
validateErrorPreview: fields.validateErrorPreview || null,
localWebErrorPreview: fields.localWebErrorPreview || null,
localApiErrorPreview: fields.localApiErrorPreview || null,
exitCode: result.exitCode,
stderr: result.exitCode === 0 ? "" : result.stderr.trim().slice(0, 2000),
};
}
function publicExposureSecretScript(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec): string {
return [
"set +e",
`namespace=${shellQuote(options.spec.runtimeNamespace)}`,
`secret=${shellQuote(exposure.secretName)}`,
`token_key=${shellQuote(exposure.tokenKey)}`,
`dry_run=${shellQuote(options.dryRun ? "true" : "false")}`,
"token=$(cat)",
"before_secret_exists=no",
"kubectl -n \"$namespace\" get secret \"$secret\" >/dev/null 2>&1 && before_secret_exists=yes",
"token_bytes=$(printf '%s' \"$token\" | wc -c | tr -d ' ')",
"apply_exit=",
"rollout_restart_exit=",
"rollout_status_exit=",
"mutation=false",
"if [ \"$dry_run\" = false ]; then",
" tmp=$(mktemp /tmp/hwlab-public-frpc-secret.XXXXXX.yaml)",
" token_b64=$(printf '%s' \"$token\" | base64 | tr -d '\\n')",
" cat >\"$tmp\" <<EOF",
"apiVersion: v1",
"kind: Secret",
"metadata:",
" name: $secret",
" namespace: $namespace",
" labels:",
" app.kubernetes.io/part-of: hwlab",
" app.kubernetes.io/managed-by: unidesk",
"type: Opaque",
"data:",
" $token_key: $token_b64",
"EOF",
" kubectl apply --server-side --force-conflicts --field-manager=unidesk-hwlab-node-public-exposure -f \"$tmp\" >/tmp/hwlab-public-frpc-secret.apply.out 2>/tmp/hwlab-public-frpc-secret.apply.err",
" apply_exit=$?",
" rm -f \"$tmp\"",
" if [ \"$apply_exit\" = 0 ]; then mutation=true; fi",
" kubectl -n \"$namespace\" rollout restart deploy/hwlab-v03-frpc >/tmp/hwlab-public-frpc-rollout-restart.out 2>/tmp/hwlab-public-frpc-rollout-restart.err",
" rollout_restart_exit=$?",
" if [ \"$rollout_restart_exit\" = 0 ]; then",
" kubectl -n \"$namespace\" rollout status deploy/hwlab-v03-frpc --timeout=60s >/tmp/hwlab-public-frpc-rollout-status.out 2>/tmp/hwlab-public-frpc-rollout-status.err",
" rollout_status_exit=$?",
" fi",
"fi",
"after_secret_exists=no",
"kubectl -n \"$namespace\" get secret \"$secret\" >/dev/null 2>&1 && after_secret_exists=yes",
"printf 'namespace\\t%s\\n' \"$namespace\"",
"printf 'secret\\t%s\\n' \"$secret\"",
"printf 'dryRun\\t%s\\n' \"$dry_run\"",
"printf 'mutation\\t%s\\n' \"$mutation\"",
"printf 'beforeSecretExists\\t%s\\n' \"$before_secret_exists\"",
"printf 'afterSecretExists\\t%s\\n' \"$after_secret_exists\"",
"printf 'tokenBytes\\t%s\\n' \"$token_bytes\"",
"printf 'applyExitCode\\t%s\\n' \"$apply_exit\"",
"printf 'rolloutRestartExitCode\\t%s\\n' \"$rollout_restart_exit\"",
"printf 'rolloutStatusExitCode\\t%s\\n' \"$rollout_status_exit\"",
"if [ \"$dry_run\" = true ]; then exit 0; fi",
"[ \"$after_secret_exists\" = yes ]",
].join("\n");
}
function publicExposureCaddyScript(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec): string {
const blockB64 = Buffer.from(publicExposureCaddyBlock(exposure), "utf8").toString("base64");
const marker = `unidesk managed ${exposure.hostname}`;
return [
"set +e",
`dry_run=${shellQuote(options.dryRun ? "true" : "false")}`,
`hostname=${shellQuote(exposure.hostname)}`,
`config_path=${shellQuote(exposure.caddyConfigPath)}`,
`service=${shellQuote(exposure.caddyServiceName)}`,
`marker=${shellQuote(marker)}`,
`block_b64=${shellQuote(blockB64)}`,
`expected_a=${shellQuote(exposure.expectedA)}`,
"rm -f /tmp/hwlab-public-caddy-validate.out /tmp/hwlab-public-caddy-validate.err /tmp/hwlab-public-caddy-web.err /tmp/hwlab-public-caddy-api.err",
"tmp=$(mktemp -d)",
"block=\"$tmp/block\"",
"next=\"$tmp/Caddyfile\"",
"printf '%s' \"$block_b64\" | base64 -d >\"$block\"",
"before_present=no",
"if [ -f \"$config_path\" ] && grep -Fq \"# BEGIN $marker\" \"$config_path\"; then before_present=yes; fi",
"if [ -f \"$config_path\" ]; then cp \"$config_path\" \"$next\"; else : >\"$next\"; fi",
"python3 - \"$next\" \"$block\" \"$marker\" <<'PY'",
"import pathlib, sys",
"config = pathlib.Path(sys.argv[1])",
"block = pathlib.Path(sys.argv[2]).read_text(encoding='utf-8')",
"marker = sys.argv[3]",
"text = config.read_text(encoding='utf-8') if config.exists() else ''",
"begin = f'# BEGIN {marker}'",
"end = f'# END {marker}'",
"managed = f'{begin}\\n{block.rstrip()}\\n{end}\\n'",
"if begin in text and end in text:",
" start = text.index(begin)",
" stop = text.index(end, start) + len(end)",
" text = text[:start].rstrip() + '\\n\\n' + managed.rstrip() + '\\n' + text[stop:].lstrip('\\n')",
"else:",
" text = text.rstrip() + '\\n\\n' + managed",
"config.write_text(text, encoding='utf-8')",
"PY",
"validate_exit=1",
"validate_mode=validate",
"if [ \"$dry_run\" = true ]; then",
" validate_mode=adapt",
" caddy adapt --config \"$next\" --adapter caddyfile >/tmp/hwlab-public-caddy-validate.out 2>/tmp/hwlab-public-caddy-validate.err",
"else",
" sudo caddy validate --config \"$next\" --adapter caddyfile >/tmp/hwlab-public-caddy-validate.out 2>/tmp/hwlab-public-caddy-validate.err",
"fi",
"validate_exit=$?",
"reload_exit=",
"mutation=false",
"if [ \"$dry_run\" = false ] && [ \"$validate_exit\" = 0 ]; then",
" sudo install -m 0644 \"$next\" \"$config_path\" >/tmp/hwlab-public-caddy-install.out 2>/tmp/hwlab-public-caddy-install.err",
" install_exit=$?",
" if [ \"$install_exit\" = 0 ]; then",
" mutation=true",
" sudo systemctl reload \"$service\" >/tmp/hwlab-public-caddy-reload.out 2>/tmp/hwlab-public-caddy-reload.err || sudo systemctl restart \"$service\" >>/tmp/hwlab-public-caddy-reload.out 2>>/tmp/hwlab-public-caddy-reload.err",
" reload_exit=$?",
" else",
" reload_exit=$install_exit",
" fi",
"fi",
"active=$(systemctl is-active \"$service\" 2>/dev/null || true)",
"after_present=no",
"if [ \"$dry_run\" = true ]; then grep -Fq \"# BEGIN $marker\" \"$next\" && after_present=yes; else grep -Fq \"# BEGIN $marker\" \"$config_path\" && after_present=yes; fi",
"local_web_status=",
"local_api_status=",
"if [ \"$dry_run\" = false ]; then",
" local_web_status=$(curl -kfsS --max-time 15 --resolve \"$hostname:443:127.0.0.1\" \"https://$hostname/\" -o /dev/null -w '%{http_code}' 2>/tmp/hwlab-public-caddy-web.err || true)",
" local_api_status=$(curl -kfsS --max-time 15 --resolve \"$hostname:443:127.0.0.1\" \"https://$hostname/health/live\" -o /dev/null -w '%{http_code}' 2>/tmp/hwlab-public-caddy-api.err || true)",
"fi",
"validate_err_preview=$([ -f /tmp/hwlab-public-caddy-validate.err ] && tr '\\n' ';' </tmp/hwlab-public-caddy-validate.err 2>/dev/null | cut -c1-1000 || true)",
"local_web_err_preview=$([ -f /tmp/hwlab-public-caddy-web.err ] && tr '\\n' ';' </tmp/hwlab-public-caddy-web.err 2>/dev/null | cut -c1-1000 || true)",
"local_api_err_preview=$([ -f /tmp/hwlab-public-caddy-api.err ] && tr '\\n' ';' </tmp/hwlab-public-caddy-api.err 2>/dev/null | cut -c1-1000 || true)",
"printf 'hostname\\t%s\\n' \"$hostname\"",
"printf 'expectedA\\t%s\\n' \"$expected_a\"",
"printf 'configPath\\t%s\\n' \"$config_path\"",
"printf 'dryRun\\t%s\\n' \"$dry_run\"",
"printf 'mutation\\t%s\\n' \"$mutation\"",
"printf 'beforeBlockPresent\\t%s\\n' \"$before_present\"",
"printf 'afterBlockPresent\\t%s\\n' \"$after_present\"",
"printf 'validateMode\\t%s\\n' \"$validate_mode\"",
"printf 'validateExitCode\\t%s\\n' \"$validate_exit\"",
"printf 'reloadExitCode\\t%s\\n' \"$reload_exit\"",
"printf 'active\\t%s\\n' \"$active\"",
"printf 'localWebStatus\\t%s\\n' \"$local_web_status\"",
"printf 'localApiStatus\\t%s\\n' \"$local_api_status\"",
"printf 'validateErrorPreview\\t%s\\n' \"$validate_err_preview\"",
"printf 'localWebErrorPreview\\t%s\\n' \"$local_web_err_preview\"",
"printf 'localApiErrorPreview\\t%s\\n' \"$local_api_err_preview\"",
"rm -rf \"$tmp\"",
"[ \"$validate_exit\" = 0 ] && [ \"$after_present\" = yes ]",
].join("\n");
}
function publicExposureCaddyBlock(exposure: HwlabRuntimePublicExposureSpec): string {
const tlsLines = exposure.caddyTls === "internal" ? " tls internal\n" : "";
return `${exposure.hostname} {
${tlsLines} @api path /health* /api* /v1* /openapi* /docs* /swagger*
reverse_proxy @api 127.0.0.1:${exposure.apiProxy.remotePort} {
transport http {
response_header_timeout ${exposure.responseHeaderTimeoutSeconds}s
}
}
reverse_proxy 127.0.0.1:${exposure.webProxy.remotePort} {
transport http {
response_header_timeout ${exposure.responseHeaderTimeoutSeconds}s
}
}
}`;
}
function runTransScript(node: string, script: string, input: string, timeoutSeconds: number): CommandResult {
return runCommand([transPath(), `${node}:k3s`, "script", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 });
}