// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. // Responsibility: shared WebProbe observe action configuration helpers. import { readFileSync } from "node:fs"; import { repoRoot, rootPath } from "../config"; import { runCommand } from "../command"; import { hwlabRuntimeLaneConfigPath, type HwlabRuntimeLaneSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeAuthLoginSpec, type HwlabRuntimeWebProbeBrowserFreezePolicySpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes"; import type { WebProbeBrowserProxyMode } from "./entry"; import { transPath } from "./runtime-common"; import { record, shellQuote } from "./utils"; export function webObserveGcDefaultKeepHours(): number { const configPath = "config/unidesk-cli.yaml"; const parsed = record(Bun.YAML.parse(readFileSync(rootPath(configPath), "utf8")) as unknown); const gc = record(parsed.gc); const scratch = record(gc.stateStaleScratch); const keepHours = scratch.keepHours; if (!Number.isInteger(keepHours) || keepHours < 0) { throw new Error(`${configPath}#gc.stateStaleScratch.keepHours must be a non-negative integer`); } return keepHours; } export function nodeWebProbeAlertThresholds(spec: HwlabRuntimeLaneSpec): HwlabRuntimeWebProbeAlertThresholdsSpec { const thresholds = spec.webProbe?.alertThresholds; if (thresholds === undefined) { throw new Error(`${hwlabRuntimeLaneConfigPath()} node=${spec.nodeId} lane=${spec.lane} requires webProbe.alertThresholds for web-probe observe`); } return thresholds; } export function nodeWebProbeBrowserFreezePolicy(spec: HwlabRuntimeLaneSpec): HwlabRuntimeWebProbeBrowserFreezePolicySpec { const policy = spec.webProbe?.browserFreezePolicy; if (policy === undefined) { throw new Error(`${hwlabRuntimeLaneConfigPath()} node=${spec.nodeId} lane=${spec.lane} requires webProbe.browserFreezePolicy for web-probe observe`); } return policy; } export function nodeWebProbeProjectManagementConfig(spec: HwlabRuntimeLaneSpec): HwlabRuntimeWebProbeProjectManagementSpec | null { return spec.webProbe?.projectManagement ?? null; } export function nodeWebProbeAuthLoginConfig(spec: HwlabRuntimeLaneSpec): HwlabRuntimeWebProbeAuthLoginSpec | null { return spec.webProbe?.authLogin ?? null; } export interface NodeWebProbeHostProxyEnv { readonly envAssignments: string[]; readonly summary: Record; } export function nodeWebProbeHostProxyEnv(spec: HwlabRuntimeLaneSpec, browserProxyMode: WebProbeBrowserProxyMode = "auto"): NodeWebProbeHostProxyEnv { if (browserProxyMode === "direct") { return { envAssignments: [], summary: { source: "option", mode: "direct", networkProfileId: spec.networkProfileId, proxy: { enabled: false }, valuesPrinted: false, }, }; } const proxy = spec.networkProfile.proxy; const serviceCache = new Map(); const http = resolveNodeWebProbeHostProxyUrl(spec, proxy.http, serviceCache); const https = resolveNodeWebProbeHostProxyUrl(spec, proxy.https, serviceCache); const all = resolveNodeWebProbeHostProxyUrl(spec, proxy.all, serviceCache); const noProxy = proxy.noProxy.join(","); return { envAssignments: [ ["HTTP_PROXY", http.url], ["HTTPS_PROXY", https.url], ["ALL_PROXY", all.url], ["http_proxy", http.url], ["https_proxy", https.url], ["all_proxy", all.url], ["NO_PROXY", noProxy], ["no_proxy", noProxy], ].map(([key, value]) => `${key}=${shellQuote(value)}`), summary: { source: "yaml", mode: "host-env", networkProfileId: spec.networkProfileId, proxy: { http: http.summary, https: https.summary, all: all.summary, noProxyCount: proxy.noProxy.length, }, valuesPrinted: false, }, }; } export function resolveNodeWebProbeHostProxyUrl( spec: HwlabRuntimeLaneSpec, rawUrl: string, serviceCache: Map, ): { url: string; summary: Record } { let parsed: URL; try { parsed = new URL(rawUrl); } catch (error) { throw new Error(`config/hwlab-node-lanes.yaml networkProfiles.${spec.networkProfileId}.proxy contains invalid proxy URL: ${error instanceof Error ? error.message : String(error)}`); } const service = parseKubernetesServiceDnsHost(parsed.hostname); if (service === null) { return { url: rawUrl, summary: { mode: "host-url", host: parsed.hostname, port: parsed.port || null, valuesPrinted: false, }, }; } const clusterIp = resolveKubernetesServiceClusterIp(spec, service.namespace, service.name, serviceCache); const originalHost = parsed.hostname; parsed.hostname = clusterIp; const resolvedUrl = normalizedProxyUrl(parsed); return { url: resolvedUrl, summary: { mode: "k8s-service-cluster-ip", service: service.name, namespace: service.namespace, originalHost, resolvedHost: clusterIp, port: parsed.port || null, valuesPrinted: false, }, }; } export function parseKubernetesServiceDnsHost(hostname: string): { name: string; namespace: string } | null { const match = hostname.toLowerCase().match(/^([a-z0-9]([-a-z0-9]*[a-z0-9])?)\.([a-z0-9]([-a-z0-9]*[a-z0-9])?)\.svc(?:\.cluster\.local)?$/u); if (match === null) return null; return { name: match[1] ?? "", namespace: match[3] ?? "" }; } export function resolveKubernetesServiceClusterIp( spec: HwlabRuntimeLaneSpec, namespace: string, serviceName: string, serviceCache: Map, ): string { const cacheKey = `${namespace}/${serviceName}`; const cached = serviceCache.get(cacheKey); if (cached !== undefined) return cached; const result = runCommand([transPath(), spec.nodeKubeRoute, "get", "svc", "-n", namespace, serviceName, "-o", "jsonpath={.spec.clusterIP}"], repoRoot, { timeoutMs: 20_000 }); const clusterIp = result.stdout.trim(); if (result.exitCode !== 0 || clusterIp.length === 0) { const reason = result.stderr.trim().slice(-500) || result.stdout.trim().slice(-500) || `exitCode=${result.exitCode}`; throw new Error(`web-probe proxy service resolution failed for ${spec.nodeId}/${spec.lane} ${namespace}/${serviceName}: ${reason}`); } serviceCache.set(cacheKey, clusterIp); return clusterIp; } export function normalizedProxyUrl(parsed: URL): string { const value = parsed.toString(); if (parsed.pathname === "/" && parsed.search === "" && parsed.hash === "") return value.replace(/\/$/u, ""); return value; } export function webProbeAccountEnvAssignments(): string[] { return Object.entries(process.env) .filter(([key, value]) => value !== undefined && /^HWLAB_WEB_(?:ACCOUNT_)?[A-Z0-9_]+_(?:JSON|USER|PASS)$/u.test(key)) .map(([key, value]) => `${key}=${shellQuote(value ?? "")}`); }