diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index c95f7e95..e8e41229 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -953,6 +953,8 @@ lanes: target: 127.0.0.1:5000/hwlab/frpc:v0.68.1 - source: openfga/openfga:v1.17.0 target: 127.0.0.1:5000/hwlab/openfga:v1.17.0 + - source: ghcr.io/anomalyco/opencode:1.17.7 + target: 127.0.0.1:5000/hwlab/opencode:1.17.7 runtimeImageBuilds: - id: moonbridge kind: moonbridge @@ -1018,6 +1020,15 @@ lanes: remotePort: 22089 localIP: hwlab-edge-proxy.hwlab-v03.svc.cluster.local localPort: 6667 + extraProxies: + - id: opencode + name: hwlab-jd01-v03-opencode + remotePort: 22091 + localIP: opencode-server.hwlab-v03.svc.cluster.local + localPort: 4096 + hostname: opencode-jd01-v03.pikapython.com + publicBaseUrl: https://opencode-jd01-v03.pikapython.com + cloudWebEnvName: HWLAB_CLOUD_WEB_OPENCODE_URL caddy: route: PK01 configPath: /etc/caddy/Caddyfile diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index edd78326..e6b84766 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -282,6 +282,13 @@ export interface HwlabRuntimePublicExposureFrpcProxySpec { readonly remotePort: number; } +export interface HwlabRuntimePublicExposureFrpcExtraProxySpec extends HwlabRuntimePublicExposureFrpcProxySpec { + readonly id: string; + readonly hostname?: string; + readonly publicBaseUrl?: string; + readonly cloudWebEnvName?: string; +} + export interface HwlabRuntimePublicExposureSpec { readonly enabled: boolean; readonly mode: "pk01-caddy-frp"; @@ -303,6 +310,7 @@ export interface HwlabRuntimePublicExposureSpec { readonly responseHeaderTimeoutSeconds: number; readonly webProxy: HwlabRuntimePublicExposureFrpcProxySpec; readonly apiProxy: HwlabRuntimePublicExposureFrpcProxySpec; + readonly extraProxies: readonly HwlabRuntimePublicExposureFrpcExtraProxySpec[]; } export interface HwlabRuntimeBootstrapAdminSpec { @@ -1207,6 +1215,32 @@ function publicExposureProxyConfig(value: unknown, path: string): HwlabRuntimePu }; } +function publicExposureExtraProxyConfig(value: unknown, path: string): HwlabRuntimePublicExposureFrpcExtraProxySpec { + const raw = asRecord(value, path); + const publicBaseUrl = optionalStringField(raw, "publicBaseUrl", path)?.replace(/\/+$/u, ""); + const hostname = optionalStringField(raw, "hostname", path); + if (publicBaseUrl !== undefined) { + validatePublicExposureOrigin(publicBaseUrl, `${path}.publicBaseUrl`); + if (hostname !== undefined && new URL(publicBaseUrl).hostname !== hostname) throw new Error(`${path}.publicBaseUrl hostname must match ${path}.hostname`); + } + const cloudWebEnvName = optionalStringField(raw, "cloudWebEnvName", path); + if (cloudWebEnvName !== undefined && !/^HWLAB_CLOUD_WEB_[A-Z0-9_]+$/u.test(cloudWebEnvName)) throw new Error(`${path}.cloudWebEnvName must be a HWLAB_CLOUD_WEB_* env name`); + if (cloudWebEnvName !== undefined && publicBaseUrl === undefined) throw new Error(`${path}.cloudWebEnvName requires ${path}.publicBaseUrl`); + return { + id: stringField(raw, "id", path), + ...publicExposureProxyConfig(value, path), + ...(hostname === undefined ? {} : { hostname }), + ...(publicBaseUrl === undefined ? {} : { publicBaseUrl }), + ...(cloudWebEnvName === undefined ? {} : { cloudWebEnvName }), + }; +} + +function publicExposureExtraProxiesConfig(value: unknown, path: string): HwlabRuntimePublicExposureFrpcExtraProxySpec[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new Error(`${path} must be an array`); + return value.map((item, index) => publicExposureExtraProxyConfig(item, `${path}[${index}]`)); +} + function publicExposureConfig(value: unknown, path: string): HwlabRuntimePublicExposureSpec | null { if (value === undefined) return null; const raw = asRecord(value, path); @@ -1237,9 +1271,21 @@ function publicExposureConfig(value: unknown, path: string): HwlabRuntimePublicE responseHeaderTimeoutSeconds: numberField(caddy, "responseHeaderTimeoutSeconds", `${path}.caddy`), webProxy: publicExposureProxyConfig(frpc.webProxy, `${path}.frpc.webProxy`), apiProxy: publicExposureProxyConfig(frpc.apiProxy, `${path}.frpc.apiProxy`), + extraProxies: publicExposureExtraProxiesConfig(frpc.extraProxies, `${path}.frpc.extraProxies`), }; } +function validatePublicExposureOrigin(publicBaseUrl: string, path: string): void { + try { + const parsed = new URL(publicBaseUrl); + if (parsed.protocol !== "https:") throw new Error("must use https"); + 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 https origin root URL: ${message}`); + } +} + function observabilityConfig(value: unknown, path: string): HwlabRuntimeObservabilitySpec { const raw = asRecord(value, path); const recordingRules = observabilityRecordingRulesConfig(raw.recordingRules, `${path}.recordingRules`); diff --git a/scripts/src/hwlab-node/public-exposure.ts b/scripts/src/hwlab-node/public-exposure.ts index e4ddda9b..c9b9b398 100644 --- a/scripts/src/hwlab-node/public-exposure.ts +++ b/scripts/src/hwlab-node/public-exposure.ts @@ -455,6 +455,7 @@ export function publicExposureSummary(exposure: HwlabRuntimePublicExposureSpec): tokenKey: exposure.tokenKey, webProxy: exposure.webProxy, apiProxy: exposure.apiProxy, + extraProxies: exposure.extraProxies, }, caddy: { route: exposure.caddyRoute, @@ -982,7 +983,7 @@ export function publicExposureCaddyScript(options: NodePublicExposureOptions, ex export function publicExposureCaddyBlock(exposure: HwlabRuntimePublicExposureSpec): string { const tlsLines = exposure.caddyTls === "internal" ? " tls internal\n" : ""; - return `${exposure.hostname} { + const mainBlock = `${exposure.hostname} { ${tlsLines} @api path /health* /auth* /v1* /json-rpc* /openapi* /docs* /swagger* reverse_proxy @api 127.0.0.1:${exposure.apiProxy.remotePort} { transport http { @@ -995,6 +996,16 @@ ${tlsLines} @api path /health* /auth* /v1* /json-rpc* /openapi* /docs* /swagg } } }`; + const extraBlocks = exposure.extraProxies + .filter((proxy) => proxy.hostname !== undefined) + .map((proxy) => `${proxy.hostname} { +${tlsLines} reverse_proxy 127.0.0.1:${proxy.remotePort} { + transport http { + response_header_timeout ${exposure.responseHeaderTimeoutSeconds}s + } + } +}`); + return [mainBlock, ...extraBlocks].join("\n\n"); } export function runTransScript(node: string, script: string, input: string, timeoutSeconds: number): CommandResult { diff --git a/scripts/src/hwlab-node/render.ts b/scripts/src/hwlab-node/render.ts index 0f1639c5..69b4ea66 100644 --- a/scripts/src/hwlab-node/render.ts +++ b/scripts/src/hwlab-node/render.ts @@ -1503,6 +1503,11 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { "function isEnvReuseContainer(container) { return envValue(container, 'HWLAB_RUNTIME_MODE') === 'env-reuse-git-mirror-checkout' || envValue(container, 'HWLAB_BOOT_SH') !== undefined || envValue(container, 'HWLAB_BOOT_COMMIT') !== undefined; }", "function workloadName(item) { return item && item.metadata && item.metadata.labels && item.metadata.labels['app.kubernetes.io/name'] ? String(item.metadata.labels['app.kubernetes.io/name']) : String(item && item.metadata && item.metadata.name || ''); }", "function expectedPublicEndpoint(item) { return workloadName(item) === 'hwlab-cloud-web' ? overlay.publicWebUrl : overlay.publicApiUrl; }", + "function publicExposureCloudWebEnvEntries() {", + " const exposure = overlay.publicExposure;", + " if (!exposure || !Array.isArray(exposure.extraProxies)) return [];", + " return exposure.extraProxies.filter((proxy) => proxy && proxy.cloudWebEnvName && proxy.publicBaseUrl).map((proxy) => ({ name: String(proxy.cloudWebEnvName), value: String(proxy.publicBaseUrl) }));", + "}", "function startupProbeFrom(probe) {", " const next = JSON.parse(JSON.stringify(probe));", " next.periodSeconds = 10;", @@ -1561,17 +1566,22 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " return changed;", "}", "function patchRuntimeEnv(item, podSpec) {", - " if (!isObject(podSpec)) return { publicEndpointChanged: false, dbSslModeChanged: false, codeAgentRuntimeChanged: false };", + " if (!isObject(podSpec)) return { publicEndpointChanged: false, dbSslModeChanged: false, codeAgentRuntimeChanged: false, cloudWebRuntimeChanged: false };", " let publicEndpointChanged = false;", " let dbSslModeChanged = false;", " let codeAgentRuntimeChanged = false;", + " let cloudWebRuntimeChanged = false;", " const pg = overlay.externalPostgres;", " const codeAgentRuntime = overlay.codeAgentRuntime;", + " const cloudWebEnvEntries = publicExposureCloudWebEnvEntries();", " for (const group of ['containers', 'initContainers']) {", " for (const container of Array.isArray(podSpec[group]) ? podSpec[group] : []) {", " if (!isObject(container)) continue;", " if (envValue(container, 'HWLAB_PUBLIC_ENDPOINT') !== undefined) publicEndpointChanged = setEnvValue(container, 'HWLAB_PUBLIC_ENDPOINT', expectedPublicEndpoint(item)) || publicEndpointChanged;", " if (pg && pg.sslmode && envValue(container, 'HWLAB_CLOUD_DB_SSL_MODE') !== undefined) dbSslModeChanged = setEnvValue(container, 'HWLAB_CLOUD_DB_SSL_MODE', pg.sslmode) || dbSslModeChanged;", + " if (workloadName(item) === 'hwlab-cloud-web' && container.name === 'hwlab-cloud-web') {", + " for (const env of cloudWebEnvEntries) cloudWebRuntimeChanged = setEnvValue(container, env.name, env.value) || cloudWebRuntimeChanged;", + " }", " if (codeAgentRuntime && codeAgentRuntime.enabled && workloadName(item) === 'hwlab-cloud-api' && container.name === 'hwlab-cloud-api') {", " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_CODE_AGENT_ADAPTER', String(codeAgentRuntime.adapter)) || codeAgentRuntimeChanged;", " codeAgentRuntimeChanged = setEnvValue(container, 'AGENTRUN_MGR_URL', String(codeAgentRuntime.managerUrl)) || codeAgentRuntimeChanged;", @@ -1593,7 +1603,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " }", " }", " }", - " return { publicEndpointChanged, dbSslModeChanged, codeAgentRuntimeChanged };", + " return { publicEndpointChanged, dbSslModeChanged, codeAgentRuntimeChanged, cloudWebRuntimeChanged };", "}", "function patchRuntimeWorkloads() {", " let observabilityChanged = false;", @@ -1628,15 +1638,16 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " changed = gitUrlChanged || changed;", " gitReadUrlChanged = gitReadUrlChanged || gitUrlChanged;", " const envChanged = patchRuntimeEnv(item, podSpecFor(item));", - " changed = envChanged.publicEndpointChanged || envChanged.dbSslModeChanged || envChanged.codeAgentRuntimeChanged || changed;", + " changed = envChanged.publicEndpointChanged || envChanged.dbSslModeChanged || envChanged.codeAgentRuntimeChanged || envChanged.cloudWebRuntimeChanged || changed;", " publicEndpointChanged = publicEndpointChanged || envChanged.publicEndpointChanged;", " dbSslModeChanged = dbSslModeChanged || envChanged.dbSslModeChanged;", " codeAgentRuntimeChanged = codeAgentRuntimeChanged || envChanged.codeAgentRuntimeChanged;", + " cloudWebRuntimeChanged = cloudWebRuntimeChanged || envChanged.cloudWebRuntimeChanged;", " }", " }", " if (changed) writeYamlDocuments(file, docs);", " }", - " return { observabilityChanged, startupProbeChanged, imageRewriteChanged, gitReadUrlChanged, publicEndpointChanged, dbSslModeChanged, codeAgentRuntimeChanged };", + " return { observabilityChanged, startupProbeChanged, imageRewriteChanged, gitReadUrlChanged, publicEndpointChanged, dbSslModeChanged, codeAgentRuntimeChanged, cloudWebRuntimeChanged };", "}", "function patchKustomization() {", " const file = path.join(runtimePath, 'kustomization.yaml');", @@ -1722,6 +1733,18 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " if (changed) writeYaml(file, normalizeList(items));", " return changed;", "}", + "function publicExposureFrpcProxies(exposure) { return [exposure.webProxy, exposure.apiProxy, ...(Array.isArray(exposure.extraProxies) ? exposure.extraProxies : [])].filter(Boolean); }", + "function renderPublicExposureFrpcProxyToml(proxy) {", + " return [", + " '[[proxies]]',", + " 'name = ' + JSON.stringify(String(proxy.name)),", + " 'type = \"tcp\"',", + " 'localIP = ' + JSON.stringify(String(proxy.localIP)),", + " 'localPort = ' + Number(proxy.localPort),", + " 'remotePort = ' + Number(proxy.remotePort),", + " '',", + " ];", + "}", "function renderPublicExposureFrpcToml(exposure) {", " return [", " 'serverAddr = ' + JSON.stringify(String(exposure.serverAddr)),", @@ -1729,20 +1752,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " '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),", - " '',", + " ...publicExposureFrpcProxies(exposure).flatMap(renderPublicExposureFrpcProxyToml),", " ].join('\\\\n');", "}", "function setEnvFromSecret(container, name, secretName, secretKey) {", @@ -1800,7 +1810,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " 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') }));", + " 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, extraProxyCount: Array.isArray(exposure.extraProxies) ? exposure.extraProxies.length : 0, configSha256: crypto.createHash('sha256').update(toml).digest('hex') }));", " return { configured: true, changed, foundConfigMap, foundDeployment };", "}", "const kustomizationChanged = patchKustomization();", @@ -1808,7 +1818,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { "const externalPostgresChanged = patchExternalPostgres();", "const healthContractChanged = patchHealthContract();", "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, codeAgentRuntimeChanged: runtimeWorkloadsChanged.codeAgentRuntimeChanged, externalPostgresChanged, healthContractChanged, publicExposureChanged }));", + "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, codeAgentRuntimeChanged: runtimeWorkloadsChanged.codeAgentRuntimeChanged, cloudWebRuntimeChanged: runtimeWorkloadsChanged.cloudWebRuntimeChanged, externalPostgresChanged, healthContractChanged, publicExposureChanged }));", "NODE_UNIDESK_RUNTIME_GITOPS_POSTPROCESS`;", "}", "function runtimeGitopsVerifyScript() {", @@ -1870,6 +1880,11 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { "function isEnvReuseContainer(container) { return envValue(container, 'HWLAB_RUNTIME_MODE') === 'env-reuse-git-mirror-checkout' || envValue(container, 'HWLAB_BOOT_SH') !== undefined || envValue(container, 'HWLAB_BOOT_COMMIT') !== undefined; }", "function workloadName(item) { return item && item.metadata && item.metadata.labels && item.metadata.labels['app.kubernetes.io/name'] ? String(item.metadata.labels['app.kubernetes.io/name']) : String(item && item.metadata && item.metadata.name || ''); }", "function expectedPublicEndpoint(item) { return workloadName(item) === 'hwlab-cloud-web' ? overlay.publicWebUrl : overlay.publicApiUrl; }", + "function publicExposureCloudWebEnvEntries() {", + " const exposure = overlay.publicExposure;", + " if (!exposure || !Array.isArray(exposure.extraProxies)) return [];", + " return exposure.extraProxies.filter((proxy) => proxy && proxy.cloudWebEnvName && proxy.publicBaseUrl).map((proxy) => ({ name: String(proxy.cloudWebEnvName), value: String(proxy.publicBaseUrl) }));", + "}", "function workloadRef(item, file, container) { return { file, kind: item && item.kind, name: item && item.metadata && item.metadata.name, container: container && container.name }; }", "function workloadChecks() {", " const metricsRefs = [];", @@ -1879,8 +1894,14 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " const wrongPublicEndpoints = [];", " const wrongDbSslModes = [];", " const wrongCodeAgentRuntimeEnvs = [];", + " const wrongCloudWebRuntimeEnvs = [];", " const rewriteSources = new Set((overlay.runtimeImageRewrites || []).map((item) => item && item.source).filter(Boolean));", " const codeAgentRuntime = overlay.codeAgentRuntime;", + " const cloudWebEnvEntries = publicExposureCloudWebEnvEntries();", + " function checkCloudWebRuntimeValue(item, file, container, envName, expected) {", + " const value = envValue(container, envName);", + " if (value !== expected) wrongCloudWebRuntimeEnvs.push({ ...workloadRef(item, file, container), envName, expected, value: value ?? null });", + " }", " function checkCodeAgentRuntimeValue(item, file, container, envName, expected) {", " const value = envValue(container, envName);", " if (value !== expected) wrongCodeAgentRuntimeEnvs.push({ ...workloadRef(item, file, container), envName, kind: 'value', expected, value: value ?? null });", @@ -1905,6 +1926,9 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " if (publicEndpoint !== undefined && publicEndpoint !== expectedPublicEndpoint(item)) wrongPublicEndpoints.push({ ...workloadRef(item, file, container), value: publicEndpoint, expected: expectedPublicEndpoint(item) });", " const dbSslMode = envValue(container, 'HWLAB_CLOUD_DB_SSL_MODE');", " if (overlay.externalPostgres && overlay.externalPostgres.sslmode && dbSslMode !== undefined && dbSslMode !== overlay.externalPostgres.sslmode) wrongDbSslModes.push({ ...workloadRef(item, file, container), value: dbSslMode, expected: overlay.externalPostgres.sslmode });", + " if (workloadName(item) === 'hwlab-cloud-web' && container.name === 'hwlab-cloud-web') {", + " for (const env of cloudWebEnvEntries) checkCloudWebRuntimeValue(item, file, container, env.name, env.value);", + " }", " if (codeAgentRuntime && codeAgentRuntime.enabled && workloadName(item) === 'hwlab-cloud-api' && container.name === 'hwlab-cloud-api') {", " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_CODE_AGENT_ADAPTER', String(codeAgentRuntime.adapter));", " checkCodeAgentRuntimeValue(item, file, container, 'AGENTRUN_MGR_URL', String(codeAgentRuntime.managerUrl));", @@ -1929,7 +1953,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " }", " }", " }", - " return { metricsRefs, missingStartupProbes, publicRuntimeImages, staleGitReadUrls, wrongPublicEndpoints, wrongDbSslModes, wrongCodeAgentRuntimeEnvs };", + " return { metricsRefs, missingStartupProbes, publicRuntimeImages, staleGitReadUrls, wrongPublicEndpoints, wrongDbSslModes, wrongCodeAgentRuntimeEnvs, wrongCloudWebRuntimeEnvs };", "}", "const checks = [];", "const workloadCheck = workloadChecks();", @@ -1953,6 +1977,8 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { "if (overlay.externalPostgres && overlay.externalPostgres.sslmode) checks.push('runtime-db-ssl-mode');", "if (workloadCheck.wrongCodeAgentRuntimeEnvs.length > 0) fail('code-agent-runtime-env-mismatch', { refs: workloadCheck.wrongCodeAgentRuntimeEnvs.slice(0, 12), count: workloadCheck.wrongCodeAgentRuntimeEnvs.length });", "if (overlay.codeAgentRuntime && overlay.codeAgentRuntime.enabled) checks.push('code-agent-runtime-env');", + "if (workloadCheck.wrongCloudWebRuntimeEnvs.length > 0) fail('cloud-web-runtime-env-mismatch', { refs: workloadCheck.wrongCloudWebRuntimeEnvs.slice(0, 12), count: workloadCheck.wrongCloudWebRuntimeEnvs.length });", + "if (publicExposureCloudWebEnvEntries().length > 0) checks.push('cloud-web-runtime-env');", "const pg = overlay.externalPostgres;", "if (pg && pg.serviceName) {", " const access = pg.runtimeAccess || { endpointAddress: pg.endpointAddress, port: pg.port };", @@ -1994,7 +2020,8 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " 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']) {", + " const expectedProxyTokens = [exposure.webProxy, exposure.apiProxy, ...(Array.isArray(exposure.extraProxies) ? exposure.extraProxies : [])].flatMap((proxy) => [String(proxy.name), String(proxy.remotePort)]);", + " for (const expected of [String(exposure.serverAddr), String(exposure.serverPort), ...expectedProxyTokens, 'HWLAB_FRP_TOKEN']) {", " if (!toml.includes(expected)) fail('public-exposure-frpc-config-mismatch', { expected });", " }", " const podSpec = podSpecFor(deployment);",