Files
pikasTech-unidesk/scripts/src/hwlab-node/web-probe-observe-action-config.ts
T

175 lines
6.8 KiB
TypeScript

// 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<string, unknown>;
}
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<string, string>();
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<string, string>,
): { url: string; summary: Record<string, unknown> } {
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, string>,
): 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 ?? "")}`);
}