diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index 5e5b2805..21221d83 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -9,7 +9,7 @@ description: UniDesk Web 开发与浏览器验证技能。用户处理 UniDesk/H ## P0 防分叉原则 -- 单一权威入口:先按 issue/CLI 明确的 node + lane 解析 workspace、public origin、namespace、sourceRef 和目标分支。没有明确目标时才读取受控 YAML;不得把 D601、G14、v0.2、v0.3、旧端口或本地 dev server 写成隐藏默认。 +- 单一权威入口:先按 issue/CLI 明确的 node + lane 解析 workspace、web-probe origin、public origin、namespace、sourceRef 和目标分支。没有明确目标时才读取受控 YAML;不得把 D601、G14、v0.2、v0.3、旧端口、ClusterIP 或本地 dev server 写成隐藏默认。 - 防分叉原则:Web/Workbench 修复必须先确定唯一 authority、唯一 API 契约和唯一状态投影入口;不得写成“当字段缺失/请求失败/状态不符/超时/刷新后,再改走另一个 endpoint、旧 workspace/conversation 模型、localStorage、trace polling、result polling、GET read-through repair、内部 manager、legacy route 或测试专用后门”。字段缺失、状态滞后或投影不完整时,修 schema、projector、read model 或正式 mutation 源头;代码里只能保留单一路径内的输入规范化、校验和错误暴露,不能用 `A ?? B`、`if missing then fallback`、兼容分支或双写双读把缺口遮住。 - 禁止状态竞争行为:不得在代码、API、reducer、read model、fake-server 或测试中保留两个事实源,再用“覆盖、压过、override、优先级、优先采用、以 A 为准否则 B、A 赢过 B”等仲裁规则决定展示状态;这就是条件分叉和竞争投影。必须先定义唯一投影对象、唯一归属字段和唯一生成时机,让 UI/API 只消费该投影对象的单一状态;其他原始字段只能作为审计输入、诊断字段或待收敛数据,不能参与展示状态仲裁。 - 严禁读侧推理:REST GET、SSE consumer、compat wrapper、CLI renderer、Web reducer/selectors、Trace renderer、fake-server 和测试断言都不能从 trace tail、message text、tool event、result cache、session summary、list row、workspace snapshot 或 localStorage 推断 turn/session/message 的 lifecycle、terminal、final response 或 running 状态。读侧只能读取唯一投影对象已经写好的字段;字段缺失、投影滞后或多字段矛盾时必须暴露 diagnostic/blocker 并修 projection writer/finalizer/schema,不得用 `result?.status ?? trace?.status`、最后一条 event `completed`、message fallback、session fallback、elapsed timeout 或 UI heuristic 补造事实。 @@ -97,6 +97,7 @@ bun scripts/cli.ts hwlab nodes web-probe observe analyze webobs-xxxx 约束: - `web-probe script` 不运行默认探针,必须通过 stdin heredoc 或 `--script-file ` 提供脚本;只需要 repo-owned 标准 DOM probe 时使用 `web-probe run`。 +- `web-probe run|script|observe start` 的默认 URL 和 browser proxy mode 必须来自 `config/hwlab-node-lanes.yaml` 的 `webProbe`;需要排除公网/FRP/跨国 proxy 抖动时,在 YAML 里把目标 node/lane 的 `webProbe.defaultOrigin` 配成内部 Service ClusterIP origin,不要在命令行长期手写 `--url` 或裸 Playwright。 - `web-probe observe start` 默认是被动观测:记录 DOM 摘要、自然页面 request/response/requestfailed、截图和 performance 样本,不主动 fetch Workbench API、不切换 control session、不拦截路由、不调用 repair helper。长程 Workbench 观测必须保留 control/observer 双页面模型:control 页面执行显式 command,observer 页面只同步到同一 session URL 后被动采样,并按默认 180000ms 周期整页刷新同一 session 来模拟用户往返;周期刷新只作用于 observer,不得改变 control active session 或作为通过条件。两页的 `pageRole`、`pageId`、`sampleGroupSeq` 必须进入样本和 analyzer 报表。任何 `newSession`、`selectProvider`、`sendPrompt`、`goto`、`screenshot`、`mark`、`stop` 都必须通过 `observe command` 显式下发,并进入 `control.jsonl`;长 prompt 必须优先用 `sendPrompt --text-stdin`,不要为了绕开 shell quoting 退回裸 Playwright 或临时脚本。 - `web-probe observe` 的 issue evidence 优先记录 observer id、stateDir、report JSON/Markdown SHA、samples/control/network/artifact 计数、routeSessionId、activeSessionId、prompt hash/textBytes、traceId、AgentRun runId/commandId、最终 status 和必要摘要;不要把 prompt 原文、assistant 大段正文、完整 stdout/stderr 或 provider payload 粘贴到 issue。 - 多轮 Workbench 采样必须证明同一个 `sessionId` 连续承载所有轮次;每轮至少记录 prompt hash、traceId、终态、最终回答摘要和性能/产物表。若 Web UI 投影卡住但 Code Agent/AgentRun result 已 terminal,应同时登记“执行终态”和“Workbench 投影未收敛”,不得用 `goto`、reload、切 session 或 result polling 把 UI 失败伪装成通过。 diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 33287f75..4410b54a 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -142,11 +142,36 @@ lanes: renderDir: runtime-v03 runtimeStore: postgres: + mode: local-k3s + secretName: hwlab-v03-postgres + statefulSet: hwlab-v03-postgres + serviceName: hwlab-v03-postgres + adminUser: hwlab_v03 + cloudApi: + secretName: hwlab-cloud-api-v03-db + secretKey: database-url + database: hwlab_v03 + role: hwlab_v03 + openfga: + secretName: hwlab-v03-openfga + secretKey: datastore-uri + authnKey: authn-preshared-key + postgresPasswordKey: postgres-password + database: hwlab_openfga + role: hwlab_openfga poolMax: 16 connectionTimeoutMs: 5000 queryRetryMaxAttempts: 5 queryRetryInitialDelayMs: 250 queryRetryMaxDelayMs: 5000 + webProbe: + browserProxyMode: direct + defaultOrigin: + mode: k8s-service-cluster-ip + serviceName: hwlab-cloud-web + namespace: hwlab-v03 + port: 8080 + scheme: http tektonDir: tekton-v03 argoApplicationFile: application-v03.yaml registryPrefix: 127.0.0.1:5000/hwlab @@ -362,27 +387,6 @@ lanes: email: ops@pikapython.com tls: auto responseHeaderTimeoutSeconds: 600 - externalPostgres: - provider: PK01 - configRef: config/platform-db/postgres-pk01.yaml - serviceName: pk01-platform-postgres - endpointAddress: 82.156.23.220 - port: 5432 - sslmode: require - database: hwlab_d601_v03 - cloudApi: - secretName: hwlab-cloud-api-v03-db - secretKey: database-url - sourceRef: hwlab/d601-v03-cloud-api-db.env - envKey: DATABASE_URL - role: hwlab_d601_v03_app - openfga: - secretName: hwlab-v03-openfga - secretKey: datastore-uri - sourceRef: hwlab/d601-v03-openfga-db.env - envKey: DATASTORE_URI - authnKey: authn-preshared-key - role: hwlab_d601_v03_app networkProfiles: node-ci-egress: diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 6714bdf1..63fe7322 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -73,9 +73,9 @@ export function hwlabNodeWebProbeHelp(): Record { description: "Run target node/lane HWLAB Cloud Web DOM probes with Web login credentials resolved from YAML-declared bootstrap admin sourceRef and injected only as one-shot stdin/env.", examples: [ "bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --wait-messages-ms 1000", - "bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --url https://hwlab.pikapython.com --fresh-session --message 'ping'", - "bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 --browser-proxy-mode direct --script-file .state/probes/workbench.mjs", - "bun scripts/cli.ts hwlab nodes web-probe observe start --node D601 --lane v03 --target-path /workbench --sample-interval-ms 5000 --browser-proxy-mode direct", + "bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --fresh-session --message 'ping'", + "bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 --script-file .state/probes/workbench.mjs", + "bun scripts/cli.ts hwlab nodes web-probe observe start --node D601 --lane v03 --target-path /workbench --sample-interval-ms 5000", "bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type newSession", "bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type selectProvider --provider codex-api", "bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type sendPrompt --text 'ping'", @@ -90,6 +90,7 @@ export function hwlabNodeWebProbeHelp(): Record { observe: "Start, inspect, control, stop, collect, and analyze a pure-client long-running Workbench observer on the target host. The observer runs a control page plus a passive observer page in a shared-auth browser context, receives commands through stateDir/commands files, writes JSONL artifacts, and does not expose any inbound service API.", }, notes: [ + "The default probe URL and browser proxy mode come from config/hwlab-node-lanes.yaml webProbe; pass --url only when intentionally overriding the YAML-selected origin.", "Prefer --script-file for reusable probes; stdin heredocs remain supported for one-off probes.", "Issue-ready evidence is available under issueEvidence and summary.issueEvidence; full script report is persisted under probe.reportPath with a SHA-256 fingerprint.", "observe sampling is passive by default: it records DOM summaries and natural page request/response/requestfailed events with observerInitiated=false; it does not actively fetch Workbench APIs, reload, switch sessions, route/intercept, or call repair helpers.", diff --git a/scripts/src/hwlab-node-impl.ts b/scripts/src/hwlab-node-impl.ts index db4a5f77..bfed6ef8 100644 --- a/scripts/src/hwlab-node-impl.ts +++ b/scripts/src/hwlab-node-impl.ts @@ -461,6 +461,10 @@ function parseNodeScopedDelegatedOptions(domain: DelegatedNodeDomain, args: stri }; } +function nodeRuntimeLocalPostgresExpectedAbsent(spec: HwlabRuntimeLaneSpec): boolean { + return spec.externalPostgres !== undefined || spec.runtimeStore?.postgres?.mode === "platform-service"; +} + function nodeRuntimeExpected(spec: HwlabRuntimeLaneSpec): Record { return { configPath: hwlabRuntimeLaneConfigPath(), @@ -508,6 +512,10 @@ function nodeRuntimeExpected(spec: HwlabRuntimeLaneSpec): Record 0 && workloadReadiness.every((item) => item.ready); - const runtimeReady = namespaceExists && localPostgresObjects.length === 0 && workloadsReady && (spec.externalPostgres === undefined || (bridge.ready && secrets.ready)); + const localPostgresExpectedAbsent = nodeRuntimeLocalPostgresExpectedAbsent(spec); + const localPostgresReady = localPostgresExpectedAbsent ? localPostgresObjects.length === 0 : localPostgresObjects.length > 0; + const runtimeReady = namespaceExists && localPostgresReady && workloadsReady && (spec.externalPostgres === undefined || (bridge.ready && secrets.ready)); const argoReady = argo.exitCode === 0 && repoURL === spec.argoRepoUrl && targetRevision === spec.gitopsBranch && path === spec.runtimePath && syncStatus === "Synced" && health === "Healthy"; const pipelineRunReady = pipelineRunProbe !== null && pipelineRunProbe.status === "True"; const pipelineRunDegradedReason = typeof pipelineRunDiagnostics?.degradedReason === "string" @@ -3877,6 +3887,8 @@ function nodeRuntimeControlPlaneStatus(scoped: ReturnType, workloadCount, workloadReady: `${readyWorkloads}/${workloadReadiness.length}`, localPostgresAbsent: runtime.localPostgresAbsent === true, + localPostgresExpectedAbsent: runtime.localPostgresExpectedAbsent === true, + localPostgresReady: runtime.localPostgresReady === true, externalPostgresReady: runtime.externalPostgresBridge === undefined && runtime.externalPostgresSecrets === undefined ? null : record(runtime.externalPostgresBridge).ready === true && record(runtime.externalPostgresSecrets).ready === true, @@ -6793,10 +6807,10 @@ function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions { action: "script", node, lane, - url: optionValue(args, "--url") ?? spec.publicWebUrl, + url: optionValue(args, "--url") ?? nodeWebProbeDefaultUrl(spec), timeoutMs, viewport: optionValue(args, "--viewport") ?? "1440x900", - browserProxyMode: parseWebProbeBrowserProxyMode(optionValue(args, "--browser-proxy-mode")), + browserProxyMode: parseWebProbeBrowserProxyMode(optionValue(args, "--browser-proxy-mode") ?? spec.webProbe?.browserProxyMode), commandTimeoutSeconds, scriptText, scriptSource: { @@ -6853,7 +6867,7 @@ function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions { action: "run", node, lane, - url: optionValue(args, "--url") ?? spec.publicWebUrl, + url: optionValue(args, "--url") ?? nodeWebProbeDefaultUrl(spec), timeoutMs, waitAfterSubmitMs, waitMessagesMs, @@ -6952,10 +6966,10 @@ function parseNodeWebProbeObserveOptions( id: observeId ?? jobId, node, lane, - url: optionValue(args, "--url") ?? spec.publicWebUrl, + url: optionValue(args, "--url") ?? (observeActionRaw === "start" ? nodeWebProbeDefaultUrl(spec) : indexed?.url ?? spec.publicWebUrl), targetPath: optionValue(args, "--target-path") ?? "/workbench", viewport: optionValue(args, "--viewport") ?? "1440x900", - browserProxyMode: parseWebProbeBrowserProxyMode(optionValue(args, "--browser-proxy-mode")), + browserProxyMode: parseWebProbeBrowserProxyMode(optionValue(args, "--browser-proxy-mode") ?? spec.webProbe?.browserProxyMode), sampleIntervalMs: positiveIntegerOption(args, "--sample-interval-ms", 5000, 600000), screenshotIntervalMs: positiveIntegerOption(args, "--screenshot-interval-ms", 300000, 86_400_000), observerRefreshIntervalMs: positiveIntegerOption(args, "--observer-refresh-interval-ms", 180000, 86_400_000), @@ -7001,6 +7015,13 @@ function parseWebProbeBrowserProxyMode(value: string | undefined): WebProbeBrows throw new Error(`web-probe --browser-proxy-mode must be auto or direct, got ${value}`); } +function nodeWebProbeDefaultUrl(spec: HwlabRuntimeLaneSpec): string { + const origin = spec.webProbe?.defaultOrigin; + if (origin === undefined || origin.mode === "public") return origin?.baseUrl ?? spec.publicWebUrl; + const clusterIp = resolveKubernetesServiceClusterIp(spec, origin.namespace, origin.serviceName, new Map()); + return `${origin.scheme}://${clusterIp}:${origin.port}`; +} + function nodeWebProbeAutoCommandTimeoutSeconds(input: { timeoutMs: number; waitAfterSubmitMs: number; @@ -10132,16 +10153,18 @@ function runtimeSecretSpec(input: { node: string; lane: string }): RuntimeSecret const namespace = `hwlab-${input.lane}`; const runtimeLaneSpec = isHwlabRuntimeLane(input.lane) ? hwlabRuntimeLaneSpecForNode(input.lane, input.node) : undefined; const externalPostgres = runtimeLaneSpec?.externalPostgres; + const postgresStore = runtimeLaneSpec?.runtimeStore?.postgres; const bootstrapAdmin = runtimeLaneSpec?.bootstrapAdmin; - const platformDb = externalPostgres !== undefined || /^v0*[3-9]\d*$/.test(input.lane); - const platformPostgresService = externalPostgres?.serviceName ?? "g14-platform-postgres"; - const legacyPostgresHost = `${namespace}-postgres.${namespace}.svc.cluster.local`; + const platformDb = externalPostgres !== undefined || postgresStore?.mode === "platform-service" || (postgresStore?.mode === undefined && /^v0*[3-9]\d*$/.test(input.lane)); + const localPostgresService = postgresStore?.serviceName ?? `${namespace}-postgres`; + const platformPostgresService = externalPostgres?.serviceName ?? postgresStore?.serviceName ?? "g14-platform-postgres"; + const legacyPostgresHost = `${localPostgresService}.${namespace}.svc.cluster.local`; const platformPostgresHost = `${platformPostgresService}.${namespace}.svc.cluster.local`; const platformPostgresEndpointSlice = `${platformPostgresService}-host`; - const openFgaDbName = externalPostgres?.database ?? (platformDb ? `openfga_${input.lane}` : "hwlab_openfga"); - const openFgaDbUser = externalPostgres?.openfga.role ?? (platformDb ? `openfga_${input.lane}_app` : "hwlab_openfga"); - const cloudApiDbName = externalPostgres?.database ?? `hwlab_${input.lane}`; - const cloudApiDbUser = externalPostgres?.cloudApi.role ?? (platformDb ? `hwlab_${input.lane}_app` : `hwlab_${input.lane}`); + const openFgaDbName = externalPostgres?.database ?? postgresStore?.openfga?.database ?? (platformDb ? `openfga_${input.lane}` : "hwlab_openfga"); + const openFgaDbUser = externalPostgres?.openfga.role ?? postgresStore?.openfga?.role ?? (platformDb ? `openfga_${input.lane}_app` : "hwlab_openfga"); + const cloudApiDbName = externalPostgres?.database ?? postgresStore?.cloudApi?.database ?? `hwlab_${input.lane}`; + const cloudApiDbUser = externalPostgres?.cloudApi.role ?? postgresStore?.cloudApi?.role ?? (platformDb ? `hwlab_${input.lane}_app` : `hwlab_${input.lane}`); return { node: input.node, lane: input.lane, @@ -10152,10 +10175,10 @@ function runtimeSecretSpec(input: { node: string; lane: string }): RuntimeSecret platformPostgresService, platformPostgresEndpointAddress: externalPostgres?.endpointAddress, platformPostgresEndpointSlice, - postgresSecret: `${namespace}-postgres`, - postgresStatefulSet: `${namespace}-postgres`, - postgresAdminUser: `hwlab_${input.lane}`, - openFgaSecret: externalPostgres?.openfga.secretName ?? `${namespace}-openfga`, + postgresSecret: postgresStore?.secretName ?? `${namespace}-postgres`, + postgresStatefulSet: postgresStore?.statefulSet ?? postgresStore?.secretName ?? `${namespace}-postgres`, + postgresAdminUser: postgresStore?.adminUser ?? `hwlab_${input.lane}`, + openFgaSecret: externalPostgres?.openfga.secretName ?? postgresStore?.openfga?.secretName ?? `${namespace}-openfga`, openFgaDbName, openFgaDbUser, openFgaDbHost: platformDb ? platformPostgresHost : legacyPostgresHost, @@ -10169,8 +10192,8 @@ function runtimeSecretSpec(input: { node: string; lane: string }): RuntimeSecret ...(bootstrapAdmin?.passwordHashTransform === undefined ? {} : { bootstrapAdminPasswordHashTransform: bootstrapAdmin.passwordHashTransform }), bootstrapAdminSourceNamespace: BOOTSTRAP_ADMIN_SOURCE_NAMESPACE, bootstrapAdminSourceSecret: BOOTSTRAP_ADMIN_SOURCE_SECRET, - cloudApiDbSecret: externalPostgres?.cloudApi.secretName ?? `hwlab-cloud-api-${input.lane}-db`, - cloudApiDbKey: externalPostgres?.cloudApi.secretKey ?? CLOUD_API_DB_KEY, + cloudApiDbSecret: externalPostgres?.cloudApi.secretName ?? postgresStore?.cloudApi?.secretName ?? `hwlab-cloud-api-${input.lane}-db`, + cloudApiDbKey: externalPostgres?.cloudApi.secretKey ?? postgresStore?.cloudApi?.secretKey ?? CLOUD_API_DB_KEY, cloudApiDbName, cloudApiDbUser, cloudApiDbHost: platformDb ? platformPostgresHost : legacyPostgresHost, diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index f3c81f99..4ffb388d 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -80,6 +80,13 @@ export interface HwlabRuntimeExternalPostgresSpec { } export interface HwlabRuntimePostgresStoreSpec { + readonly mode?: "local-k3s" | "platform-service"; + readonly secretName?: string; + readonly statefulSet?: string; + readonly serviceName?: string; + readonly adminUser?: string; + readonly cloudApi?: HwlabRuntimePostgresStoreComponentSpec; + readonly openfga?: HwlabRuntimePostgresStoreComponentSpec; readonly poolMax: number; readonly connectionTimeoutMs?: number; readonly queryRetryMaxAttempts?: number; @@ -87,10 +94,39 @@ export interface HwlabRuntimePostgresStoreSpec { readonly queryRetryMaxDelayMs?: number; } +export interface HwlabRuntimePostgresStoreComponentSpec { + readonly secretName: string; + readonly secretKey: string; + readonly database: string; + readonly role: string; + readonly authnKey?: string; + readonly postgresPasswordKey?: string; +} + export interface HwlabRuntimeStoreSpec { readonly postgres?: HwlabRuntimePostgresStoreSpec; } +export interface HwlabRuntimeWebProbeServiceOriginSpec { + readonly mode: "k8s-service-cluster-ip"; + readonly serviceName: string; + readonly namespace: string; + readonly port: number; + readonly scheme: "http" | "https"; +} + +export interface HwlabRuntimeWebProbePublicOriginSpec { + readonly mode: "public"; + readonly baseUrl?: string; +} + +export type HwlabRuntimeWebProbeOriginSpec = HwlabRuntimeWebProbeServiceOriginSpec | HwlabRuntimeWebProbePublicOriginSpec; + +export interface HwlabRuntimeWebProbeSpec { + readonly browserProxyMode?: "auto" | "direct"; + readonly defaultOrigin?: HwlabRuntimeWebProbeOriginSpec; +} + export interface HwlabRuntimeBuildkitSpec { readonly sidecarImage: string; } @@ -230,6 +266,7 @@ export interface HwlabRuntimeLaneSpec { readonly bootstrapAdmin?: HwlabRuntimeBootstrapAdminSpec; readonly externalPostgres?: HwlabRuntimeExternalPostgresSpec; readonly runtimeStore?: HwlabRuntimeStoreSpec; + readonly webProbe?: HwlabRuntimeWebProbeSpec; readonly publicExposure: HwlabRuntimePublicExposureSpec | null; readonly observability: HwlabRuntimeObservabilitySpec; readonly runtimeImageRewrites: readonly HwlabRuntimeImageRewriteSpec[]; @@ -272,6 +309,7 @@ interface HwlabLaneConfig { readonly bootstrapAdmin?: HwlabRuntimeBootstrapAdminSpec; readonly externalPostgres?: HwlabRuntimeExternalPostgresSpec; readonly runtimeStore?: HwlabRuntimeStoreSpec; + readonly webProbe?: HwlabRuntimeWebProbeSpec; readonly publicExposure: HwlabRuntimePublicExposureSpec | null; readonly observability: HwlabRuntimeObservabilitySpec; readonly runtimeImageRewrites: readonly HwlabRuntimeImageRewriteSpec[]; @@ -490,6 +528,7 @@ function laneConfig(id: HwlabRuntimeLane, raw: Record): HwlabLa bootstrapAdmin: bootstrapAdminConfig(raw.bootstrapAdmin, `lanes.${id}.bootstrapAdmin`), externalPostgres: externalPostgresConfig(raw.externalPostgres, `lanes.${id}.externalPostgres`), runtimeStore: runtimeStoreConfig(raw.runtimeStore, `lanes.${id}.runtimeStore`), + webProbe: webProbeConfig(raw.webProbe, `lanes.${id}.webProbe`), publicExposure: publicExposureConfig(raw.publicExposure, `lanes.${id}.publicExposure`), observability: observabilityConfig(raw.observability, `lanes.${id}.observability`), runtimeImageRewrites: runtimeImageRewritesConfig(raw.runtimeImageRewrites, `lanes.${id}.runtimeImageRewrites`), @@ -510,6 +549,7 @@ function laneTargetConfig(id: HwlabRuntimeLane, nodeId: string, baseRaw: Record< bootstrapAdmin: mergeOptionalRecord(baseRaw.bootstrapAdmin, targetRaw.bootstrapAdmin), externalPostgres: mergeOptionalRecord(baseRaw.externalPostgres, targetRaw.externalPostgres), runtimeStore: mergeOptionalRecord(baseRaw.runtimeStore, targetRaw.runtimeStore), + webProbe: mergeOptionalRecord(baseRaw.webProbe, targetRaw.webProbe), publicExposure: mergeOptionalRecord(baseRaw.publicExposure, targetRaw.publicExposure), observability: mergeOptionalRecord(baseRaw.observability, targetRaw.observability), }; @@ -595,6 +635,13 @@ function runtimeStoreConfig(value: unknown, path: string): HwlabRuntimeStoreSpec ? {} : { postgres: { + ...(postgres.mode === undefined ? {} : { mode: postgresModeField(postgres, `${path}.postgres`) }), + ...(postgres.secretName === undefined ? {} : { secretName: stringField(postgres, "secretName", `${path}.postgres`) }), + ...(postgres.statefulSet === undefined ? {} : { statefulSet: stringField(postgres, "statefulSet", `${path}.postgres`) }), + ...(postgres.serviceName === undefined ? {} : { serviceName: stringField(postgres, "serviceName", `${path}.postgres`) }), + ...(postgres.adminUser === undefined ? {} : { adminUser: stringField(postgres, "adminUser", `${path}.postgres`) }), + ...(postgres.cloudApi === undefined ? {} : { cloudApi: runtimeStorePostgresComponentConfig(postgres.cloudApi, `${path}.postgres.cloudApi`) }), + ...(postgres.openfga === undefined ? {} : { openfga: runtimeStorePostgresComponentConfig(postgres.openfga, `${path}.postgres.openfga`) }), poolMax: numberField(postgres, "poolMax", `${path}.postgres`), ...(postgres.connectionTimeoutMs === undefined ? {} @@ -613,6 +660,62 @@ function runtimeStoreConfig(value: unknown, path: string): HwlabRuntimeStoreSpec }; } +function postgresModeField(raw: Record, path: string): "local-k3s" | "platform-service" { + const mode = stringField(raw, "mode", path); + if (mode !== "local-k3s" && mode !== "platform-service") throw new Error(`${path}.mode must be local-k3s or platform-service`); + return mode; +} + +function runtimeStorePostgresComponentConfig(value: unknown, path: string): HwlabRuntimePostgresStoreComponentSpec { + const raw = asRecord(value, path); + return { + secretName: stringField(raw, "secretName", path), + secretKey: secretKeyField(raw, "secretKey", path), + database: stringField(raw, "database", path), + role: stringField(raw, "role", path), + authnKey: optionalStringField(raw, "authnKey", path), + postgresPasswordKey: optionalStringField(raw, "postgresPasswordKey", path), + }; +} + +function webProbeConfig(value: unknown, path: string): HwlabRuntimeWebProbeSpec | undefined { + if (value === undefined) return undefined; + const raw = asRecord(value, path); + const rawBrowserProxyMode = optionalStringField(raw, "browserProxyMode", path); + let browserProxyMode: "auto" | "direct" | undefined; + if (rawBrowserProxyMode !== undefined) { + if (rawBrowserProxyMode !== "auto" && rawBrowserProxyMode !== "direct") { + throw new Error(`${path}.browserProxyMode must be auto or direct`); + } + browserProxyMode = rawBrowserProxyMode; + } + return { + ...(browserProxyMode === undefined ? {} : { browserProxyMode }), + ...(raw.defaultOrigin === undefined ? {} : { defaultOrigin: webProbeOriginConfig(raw.defaultOrigin, `${path}.defaultOrigin`) }), + }; +} + +function webProbeOriginConfig(value: unknown, path: string): HwlabRuntimeWebProbeOriginSpec { + const raw = asRecord(value, path); + const mode = stringField(raw, "mode", path); + if (mode === "public") { + return { + mode, + ...(raw.baseUrl === undefined ? {} : { baseUrl: stringField(raw, "baseUrl", path).replace(/\/+$/u, "") }), + }; + } + if (mode !== "k8s-service-cluster-ip") throw new Error(`${path}.mode must be public or k8s-service-cluster-ip`); + const scheme = stringField(raw, "scheme", path); + if (scheme !== "http" && scheme !== "https") throw new Error(`${path}.scheme must be http or https`); + return { + mode, + serviceName: stringField(raw, "serviceName", path), + namespace: stringField(raw, "namespace", path), + port: numberField(raw, "port", path), + scheme, + }; +} + function publicExposureProxyConfig(value: unknown, path: string): HwlabRuntimePublicExposureFrpcProxySpec { const raw = asRecord(value, path); return { @@ -887,6 +990,7 @@ function buildRuntimeLaneSpec(config: HwlabLaneConfig): HwlabRuntimeLaneSpec { ...(config.bootstrapAdmin === undefined ? {} : { bootstrapAdmin: config.bootstrapAdmin }), ...(config.externalPostgres === undefined ? {} : { externalPostgres: config.externalPostgres }), ...(config.runtimeStore === undefined ? {} : { runtimeStore: config.runtimeStore }), + ...(config.webProbe === undefined ? {} : { webProbe: config.webProbe }), publicExposure: config.publicExposure, observability: config.observability, runtimeImageRewrites: config.runtimeImageRewrites,