Files
pikasTech-unidesk/scripts/src/hwlab-node/web-probe.ts
T
2026-06-26 14:01:49 +08:00

1426 lines
71 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. web-probe module for scripts/src/hwlab-node-impl.ts.
// Moved mechanically from scripts/src/hwlab-node-impl.ts:6032-7426 for #903.
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// Responsibility: YAML-first node/lane operations, including Workbench observability control commands.
import { createHash, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand, type CommandResult } from "../command";
import { startJob } from "../jobs";
import { classifySshTcpPoolFailure } from "../ssh";
import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "../hwlab-node-control-plane";
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes";
import { nodeWebProbeScriptRunnerSource } from "../hwlab-node-web-probe-runner-source";
import { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source";
import { nodeWebObserveRunnerSource } from "../hwlab-node-web-observe-runner-source";
import { nodeWebObserveCollectViewNodeScript, parseNodeWebProbeObserveCollectView, type NodeWebProbeObserveCollectView } from "../hwlab-node-web-observe-collect";
import { withWebObserveCollectRendered, withWebObserveCommandRendered, withWebObserveStatusRendered } from "../hwlab-node-web-observe-render";
import { buildWebObserveWrapperForObserveOptions, webObserveWrapperStateDirFromStatus } from "../hwlab-node-web-observe-wrapper";
import { renderWebObserveWrapperContract } from "../hwlab-node-web-observe-wrapper-render";
import { runWebProbeSentinelCommand, type WebProbeSentinelOptions } from "../hwlab-node-web-sentinel-cicd";
import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from "../hwlab-node-help";
import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary";
import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql";
import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport";
import type { RenderedCliResult } from "../output";
import type { BootstrapAdminPasswordMaterial, BootstrapAdminSecretMaterial, NodeRuntimeGitMirrorEgressProxySpec, NodeRuntimeGitMirrorGithubTransportSpec, NodeRuntimeGitMirrorTargetSpec, NodeRuntimeRenderResult, NodeWebProbeOptions, RuntimeSecretSpec } from "./entry";
import { isCommandSuccess, nodeRuntimeGitopsRoot, runNodeK3sArgs, runNodeK3sScript } from "./cleanup";
import { nodeRuntimeCicdWaitWarning } from "./git-mirror";
import { parseNodeScopedDelegatedOptions } from "./plan";
import { nodeRuntimePipelineFailureSummary, nodeRuntimePipelineRunDiagnostics } from "./render";
import { compactRuntimeCommand, runNodeHostScript } from "./runtime-common";
import { assertLane, assertNodeId, keyValueLinesFromText, numericField, optionValue, optionalStringValue, positiveIntegerOption, positiveIntegerValue, record, requiredOption, shellQuote, statusText, stringValue, stripOptions } from "./utils";
import { discoverWebObserveIndexEntry, readWebObserveIndexEntry } from "./web-observe-render";
import { assertKnownOptions, nodeWebProbeAutoCommandTimeoutSeconds, nodeWebProbeDefaultUrl, normalizeNodeWebProbeObserveArgs, parseNodeWebProbeObserveOptions, parseNodeWebProbeSentinelOptions, parseWebProbeBrowserProxyMode } from "./web-probe-observe";
import { hwlabRuntimeActiveExternalPostgres } from "../hwlab-node-lanes";
export function nodeRuntimeRenderOverlay(spec: HwlabRuntimeLaneSpec): Record<string, unknown> {
const gitSshProxy = httpProxyEndpoint(spec.networkProfile.proxy.http);
const gitMirror = nodeRuntimeGitMirrorTarget(spec);
const activeExternalPostgres = hwlabRuntimeActiveExternalPostgres(spec);
const renderGitMirror = {
...gitMirror,
egressProxy: gitMirror.egressProxy.mode === "direct" ? {
mode: "direct",
required: false,
} : {
...gitMirror.egressProxy,
mode: "node-global",
required: true,
},
};
return {
nodeId: spec.nodeId,
lane: spec.lane,
sourceBranch: spec.sourceBranch,
gitopsBranch: spec.gitopsBranch,
gitopsRoot: nodeRuntimeGitopsRoot(spec),
runtimePath: spec.runtimePath,
runtimeRenderDir: spec.runtimeRenderDir,
runtimeNamespace: spec.runtimeNamespace,
catalogPath: spec.catalogPath,
tektonDir: spec.tektonDir,
argoApplicationFile: spec.argoApplicationFile,
argoRepoUrl: spec.argoRepoUrl,
gitUrl: spec.gitUrl,
gitReadUrl: spec.gitReadUrl,
gitWriteUrl: spec.gitWriteUrl,
toolsImage: gitMirror.toolsImage,
toolsImagePullPolicy: gitMirror.toolsImagePullPolicy,
gitMirror: renderGitMirror,
networkProfileId: spec.networkProfileId,
downloadProfileId: spec.downloadProfileId,
gitSshProxyHost: gitSshProxy?.host,
gitSshProxyPort: gitSshProxy?.port,
proxyHttp: spec.networkProfile.proxy.http,
proxyHttps: spec.networkProfile.proxy.https,
proxyAll: spec.networkProfile.proxy.all,
noProxy: spec.networkProfile.proxy.noProxy.join(","),
dockerProxyHttp: spec.networkProfile.dockerBuildProxy.http,
dockerProxyHttps: spec.networkProfile.dockerBuildProxy.https,
dockerProxyAll: spec.networkProfile.dockerBuildProxy.all,
dockerNoProxy: spec.networkProfile.dockerBuildProxy.noProxy.join(","),
dockerNoProxyList: spec.networkProfile.dockerBuildProxy.noProxy,
npmRegistry: spec.downloadProfile.npm.registry,
npmFetchTimeoutMs: spec.downloadProfile.npm.fetchTimeoutSeconds * 1000,
npmRetries: spec.downloadProfile.npm.retries,
stepEnv: spec.stepEnv,
observability: spec.observability,
runtimeStore: spec.runtimeStore,
runtimeImageRewrites: spec.runtimeImageRewrites,
registryPrefix: spec.registryPrefix,
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: activeExternalPostgres === undefined ? undefined : {
enabled: true,
serviceName: activeExternalPostgres.serviceName,
endpointAddress: activeExternalPostgres.endpointAddress,
port: activeExternalPostgres.port,
runtimeAccess: activeExternalPostgres.runtimeAccess ?? null,
sslmode: activeExternalPostgres.sslmode,
},
};
}
export function httpProxyEndpoint(value: string): { host: string; port: number } | null {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return null;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
const port = parsed.port === "" ? parsed.protocol === "https:" ? 443 : 80 : Number.parseInt(parsed.port, 10);
if (!Number.isInteger(port) || port <= 0) return null;
return { host: parsed.hostname, port };
}
export function nodeRuntimeControlPlaneFiles(spec: HwlabRuntimeLaneSpec, renderDir: string): string[] {
return [
`${renderDir}/devops-infra/git-mirror.yaml`,
`${renderDir}/${spec.runtimeRenderDir}/namespace.yaml`,
`${renderDir}/${spec.tektonDir}/rbac.yaml`,
`${renderDir}/${spec.tektonDir}/pipeline.yaml`,
`${renderDir}/argocd/project.yaml`,
`${renderDir}/argocd/${spec.argoApplicationFile}`,
];
}
export function applyNodeRuntimeControlPlaneFiles(spec: HwlabRuntimeLaneSpec, renderDir: string, dryRun: boolean, timeoutSeconds: number): CommandResult {
const files = nodeRuntimeControlPlaneFiles(spec, renderDir);
const args = [
"kubectl",
"apply",
...(dryRun ? ["--dry-run=client"] : ["--server-side", "--force-conflicts", `--field-manager=${spec.controlPlaneFieldManager}`]),
...files.flatMap((file) => ["-f", file]),
];
return runNodeK3sArgs(spec, args, timeoutSeconds);
}
export function applyLocalNodeRuntimeControlPlaneFiles(spec: HwlabRuntimeLaneSpec, renderDir: string, dryRun: boolean, timeoutSeconds: number): CommandResult {
const manifest = nodeRuntimeControlPlaneFiles(spec, renderDir)
.map((file) => `---\n# Source: ${file}\n${readFileSync(file, "utf8").trimEnd()}\n`)
.join("");
const args = [
"kubectl",
"apply",
...(dryRun ? ["--dry-run=client"] : ["--server-side", "--force-conflicts", `--field-manager=${spec.controlPlaneFieldManager}`]),
"-f",
"-",
];
return runNodeK3sScript(spec, args.map(shellQuote).join(" "), timeoutSeconds, manifest);
}
export function cleanupNodeRuntimeRenderDir(spec: HwlabRuntimeLaneSpec, renderDir: string): CommandResult {
const prefix = `/tmp/hwlab-${spec.nodeId.toLowerCase()}-${spec.lane}-control-plane-`;
const script = [
"set -eu",
`render_dir=${shellQuote(renderDir)}`,
`prefix=${shellQuote(prefix)}`,
"case \"$render_dir\" in",
" \"$prefix\"*) rm -rf \"$render_dir\" ;;",
" *) echo \"refusing to remove unexpected render dir: $render_dir\" >&2; exit 44 ;;",
"esac",
].join("\n");
return runNodeHostScript(spec, script, 60);
}
export function cleanupLocalNodeRuntimeRenderDir(spec: HwlabRuntimeLaneSpec, render: NodeRuntimeRenderResult): CommandResult {
const renderPrefix = `/tmp/hwlab-${spec.nodeId.toLowerCase()}-${spec.lane}-control-plane-`;
const worktreePrefix = `/tmp/hwlab-${spec.nodeId.toLowerCase()}-${spec.lane}-source-`;
if (!render.renderDir.startsWith(renderPrefix) || !render.worktreeDir.startsWith(worktreePrefix)) {
return {
command: ["rm", "-rf", "<refused>"],
cwd: repoRoot,
exitCode: 44,
stdout: "",
stderr: `refusing to remove unexpected local render paths: ${render.renderDir} ${render.worktreeDir}`,
signal: null,
timedOut: false,
};
}
return runCommand(["rm", "-rf", render.renderDir, render.worktreeDir], repoRoot, { timeoutMs: 60_000 });
}
export function nodeRuntimePipelineRunManifest(spec: HwlabRuntimeLaneSpec, sourceCommit: string, pipelineRun: string): Record<string, unknown> {
return {
apiVersion: "tekton.dev/v1",
kind: "PipelineRun",
metadata: {
name: pipelineRun,
namespace: "hwlab-ci",
labels: {
"app.kubernetes.io/part-of": "hwlab",
"hwlab.pikastech.local/gitops-target": spec.lane,
"hwlab.pikastech.local/source-commit": sourceCommit,
"hwlab.pikastech.local/trigger": "unidesk-node-cli",
},
annotations: {
"hwlab.pikastech.local/node": spec.nodeId,
"hwlab.pikastech.local/source-branch": spec.sourceBranch,
"hwlab.pikastech.local/gitops-branch": spec.gitopsBranch,
"hwlab.pikastech.local/runtime-path": spec.runtimePath,
"hwlab.pikastech.local/network-profile": spec.networkProfileId,
"hwlab.pikastech.local/download-profile": spec.downloadProfileId,
},
},
spec: {
pipelineRef: { name: spec.pipeline },
taskRunTemplate: {
serviceAccountName: spec.serviceAccountName,
podTemplate: {
hostNetwork: true,
dnsPolicy: "ClusterFirstWithHostNet",
securityContext: { fsGroup: 1000 },
},
},
params: [
{ name: "git-url", value: spec.gitUrl },
{ name: "git-read-url", value: spec.gitReadUrl },
{ name: "git-write-url", value: spec.gitWriteUrl },
{ name: "source-branch", value: spec.sourceBranch },
{ name: "gitops-branch", value: spec.gitopsBranch },
{ name: "lane", value: spec.lane },
{ name: "catalog-path", value: spec.catalogPath },
{ name: "image-tag-mode", value: "full" },
{ name: "runtime-path", value: spec.runtimePath },
{ name: "revision", value: sourceCommit },
{ name: "registry-prefix", value: spec.registryPrefix },
{ name: "services", value: spec.serviceIds.join(",") },
{ name: "base-image", value: spec.baseImage },
],
workspaces: [
{ name: "source", volumeClaimTemplate: { spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "8Gi" } } } } },
{ name: "git-ssh", secret: { secretName: "hwlab-git-ssh" } },
],
},
};
}
export function createNodeRuntimePipelineRun(spec: HwlabRuntimeLaneSpec, sourceCommit: string, pipelineRun: string, timeoutSeconds: number, rerun: boolean): CommandResult {
const manifestB64 = Buffer.from(JSON.stringify(nodeRuntimePipelineRunManifest(spec, sourceCommit, pipelineRun)), "utf8").toString("base64");
const script = [
"set -eu",
`manifest_b64=${shellQuote(manifestB64)}`,
`pipeline_run=${shellQuote(pipelineRun)}`,
`rerun=${rerun ? "true" : "false"}`,
"manifest_path=\"/tmp/$pipeline_run.json\"",
"printf '%s' \"$manifest_b64\" | base64 -d > \"$manifest_path\"",
"existing_status=$(kubectl get pipelinerun -n hwlab-ci \"$pipeline_run\" -o 'jsonpath={.status.conditions[0].status}' 2>/dev/null || true)",
"if [ \"$rerun\" = true ] && [ -n \"$existing_status\" ]; then",
" kubectl delete pipelinerun -n hwlab-ci \"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
" kubectl delete taskrun -n hwlab-ci -l tekton.dev/pipelineRun=\"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
" kubectl delete pod -n hwlab-ci -l tekton.dev/pipelineRun=\"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
" kubectl -n hwlab-ci get pvc -o name | grep '^persistentvolumeclaim/pvc-' | xargs -r kubectl -n hwlab-ci delete --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
"elif [ \"$existing_status\" = False ]; then",
" kubectl delete pipelinerun -n hwlab-ci \"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
" kubectl delete taskrun -n hwlab-ci -l tekton.dev/pipelineRun=\"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
" kubectl delete pod -n hwlab-ci -l tekton.dev/pipelineRun=\"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
" kubectl -n hwlab-ci get pvc -o name | grep '^persistentvolumeclaim/pvc-' | xargs -r kubectl -n hwlab-ci delete --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
"fi",
"if kubectl create -f \"$manifest_path\"; then",
" :",
"else",
" code=$?",
" if kubectl get pipelinerun -n hwlab-ci \"$pipeline_run\" >/dev/null 2>&1; then",
" printf 'PipelineRun %s already exists; reusing existing object\\n' \"$pipeline_run\" >&2",
" else",
" exit \"$code\"",
" fi",
"fi",
"kubectl get pipelinerun -n hwlab-ci \"$pipeline_run\" -o jsonpath='{.metadata.name}{\"\\n\"}{.metadata.labels.hwlab\\.pikastech\\.local/source-commit}{\"\\n\"}{.status.conditions[0].status}{\"\\n\"}{.status.conditions[0].reason}{\"\\n\"}'",
].join("\n");
return runNodeK3sScript(spec, script, timeoutSeconds);
}
export function waitForNodeRuntimePipelineRunTerminal(
spec: HwlabRuntimeLaneSpec,
pipelineRun: string,
timeoutSeconds: number,
options: { opportunisticPostFlush?: () => Record<string, unknown> | null; opportunisticPostSync?: () => Record<string, unknown> | null } = {},
): Record<string, unknown> {
const severeWarningThresholdMs = 120_000;
const startedAt = Date.now();
const deadline = startedAt + timeoutSeconds * 1000;
let polls = 0;
let last: Record<string, unknown> = { exists: false, name: pipelineRun };
let lastOpportunisticPostFlushAt = 0;
let severeWarningEmitted = false;
let opportunisticPostSyncAttempted = false;
const opportunisticPostFlushes: Record<string, unknown>[] = [];
const opportunisticPostSyncs: Record<string, unknown>[] = [];
printNodeRuntimeTriggerProgress(spec, { stage: "pipelinerun-wait", status: "started", pipelineRun, timeoutSeconds });
while (Date.now() <= deadline) {
polls += 1;
last = getNodeRuntimePipelineRun(spec, pipelineRun);
const status = typeof last.status === "string" ? last.status : null;
const reason = typeof last.reason === "string" ? last.reason : null;
const elapsedMs = Date.now() - startedAt;
printNodeRuntimeTriggerProgress(spec, { stage: "pipelinerun-wait", status: "poll", pipelineRun, pipelineStatus: status, reason, polls, elapsedMs });
if (!severeWarningEmitted && elapsedMs >= severeWarningThresholdMs && status !== "True" && status !== "False") {
severeWarningEmitted = true;
printNodeRuntimeTriggerProgress(spec, {
stage: "pipelinerun-wait",
status: "warning",
warning: "pipelinerun-wait-severe-timeout",
severity: "warning",
pipelineRun,
pipelineStatus: status,
reason,
polls,
elapsedMs,
thresholdMs: severeWarningThresholdMs,
message: "PipelineRun has been non-terminal for more than 120s; this is a severe timeout and requires further investigation of Tekton taskRuns/pods instead of treating it as normal wait time.",
next: {
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${spec.nodeId} --lane ${spec.lane} --pipeline-run ${pipelineRun} --full`,
},
});
}
if (status === "True" || status === "False") {
const ok = status === "True";
const diagnostics = ok ? null : nodeRuntimePipelineRunDiagnostics(spec, pipelineRun);
const failureSummary = nodeRuntimePipelineFailureSummary(diagnostics);
printNodeRuntimeTriggerProgress(spec, { stage: "pipelinerun-wait", status: ok ? "succeeded" : "failed", pipelineRun, pipelineStatus: status, reason, polls, elapsedMs: Date.now() - startedAt });
return {
ok,
status: ok ? "succeeded" : "failed",
pipelineRun: last,
diagnostics,
failureSummary,
polls,
elapsedMs: Date.now() - startedAt,
opportunisticPostSyncs: opportunisticPostSyncs.length > 0 ? opportunisticPostSyncs : undefined,
opportunisticPostFlushes: opportunisticPostFlushes.length > 0 ? opportunisticPostFlushes : undefined,
degradedReason: ok ? undefined : "node-runtime-pipelinerun-failed",
};
}
if (
options.opportunisticPostSync !== undefined
&& !opportunisticPostSyncAttempted
&& nodeRuntimePipelineRunSourceCloneCompleted(spec, pipelineRun)
) {
opportunisticPostSyncAttempted = true;
const sync = options.opportunisticPostSync();
if (sync !== null) opportunisticPostSyncs.push(sync);
}
if (options.opportunisticPostFlush !== undefined && Date.now() - lastOpportunisticPostFlushAt >= 15_000) {
lastOpportunisticPostFlushAt = Date.now();
const flush = options.opportunisticPostFlush();
if (flush !== null) opportunisticPostFlushes.push(flush);
last = getNodeRuntimePipelineRun(spec, pipelineRun);
const refreshedStatus = typeof last.status === "string" ? last.status : null;
const refreshedReason = typeof last.reason === "string" ? last.reason : null;
const refreshedElapsedMs = Date.now() - startedAt;
if (refreshedStatus === "True" || refreshedStatus === "False") {
const ok = refreshedStatus === "True";
const diagnostics = ok ? null : nodeRuntimePipelineRunDiagnostics(spec, pipelineRun);
const failureSummary = nodeRuntimePipelineFailureSummary(diagnostics);
printNodeRuntimeTriggerProgress(spec, { stage: "pipelinerun-wait", status: ok ? "succeeded" : "failed", pipelineRun, pipelineStatus: refreshedStatus, reason: refreshedReason, polls, elapsedMs: refreshedElapsedMs, observedAfterPostFlush: true });
return {
ok,
status: ok ? "succeeded" : "failed",
pipelineRun: last,
diagnostics,
failureSummary,
polls,
elapsedMs: refreshedElapsedMs,
opportunisticPostSyncs: opportunisticPostSyncs.length > 0 ? opportunisticPostSyncs : undefined,
opportunisticPostFlushes: opportunisticPostFlushes.length > 0 ? opportunisticPostFlushes : undefined,
observedAfterPostFlush: true,
degradedReason: ok ? undefined : "node-runtime-pipelinerun-failed",
};
}
}
sleepSync(Math.min(10_000, Math.max(1000, deadline - Date.now())));
}
const elapsedMs = Date.now() - startedAt;
const warning = nodeRuntimeCicdWaitWarning(spec, pipelineRun, { polls, elapsedMs });
printNodeRuntimeTriggerProgress(spec, { stage: "pipelinerun-wait", status: "warning", pipelineRun, polls, elapsedMs, waitLimitSeconds: timeoutSeconds });
return {
ok: true,
status: "pending",
pipelineRun: last,
polls,
elapsedMs,
waitLimitSeconds: timeoutSeconds,
warning,
opportunisticPostSyncs: opportunisticPostSyncs.length > 0 ? opportunisticPostSyncs : undefined,
opportunisticPostFlushes: opportunisticPostFlushes.length > 0 ? opportunisticPostFlushes : undefined,
next: { status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${spec.nodeId} --lane ${spec.lane} --pipeline-run ${pipelineRun}` },
};
}
export function nodeRuntimePipelineRunSourceCloneCompleted(spec: HwlabRuntimeLaneSpec, pipelineRun: string): boolean {
const result = runNodeK3sArgs(spec, ["kubectl", "-n", "hwlab-ci", "get", "taskrun", "-l", `tekton.dev/pipelineRun=${pipelineRun}`, "-o", "json"], 60);
if (result.exitCode !== 0) return false;
let parsed: unknown;
try {
parsed = JSON.parse(result.stdout);
} catch {
return false;
}
const items = Array.isArray(record(parsed).items) ? record(parsed).items as unknown[] : [];
return items.some((item) => {
const itemRecord = record(item);
const metadata = record(itemRecord.metadata);
const labels = record(metadata.labels);
const name = typeof metadata.name === "string" ? metadata.name : "";
const pipelineTask = typeof labels["tekton.dev/pipelineTask"] === "string" ? labels["tekton.dev/pipelineTask"] : "";
const taskName = `${pipelineTask} ${name}`;
if (!/(clone|checkout|local-git|source[-_ ]*worktree)/iu.test(taskName)) return false;
const status = record(itemRecord.status);
const conditions = Array.isArray(status.conditions) ? status.conditions as unknown[] : [];
return conditions.some((condition) => {
const conditionRecord = record(condition);
return conditionRecord.type === "Succeeded" && conditionRecord.status === "True";
});
});
}
export function printNodeRuntimeTriggerProgress(spec: HwlabRuntimeLaneSpec, data: Record<string, unknown> = {}): void {
process.stderr.write(`${JSON.stringify({ event: "hwlab.runtime-lane.trigger.progress", at: new Date().toISOString(), lane: spec.lane, node: spec.nodeId, ...data })}\n`);
}
export function getNodeRuntimePipelineRun(spec: HwlabRuntimeLaneSpec, pipelineRun: string): Record<string, unknown> {
const result = runNodeK3sArgs(spec, ["kubectl", "-n", "hwlab-ci", "get", "pipelinerun", pipelineRun, "-o", "jsonpath={.status.conditions[0].status}{\"\\n\"}{.status.conditions[0].reason}{\"\\n\"}{.status.conditions[0].message}{\"\\n\"}{.metadata.creationTimestamp}{\"\\n\"}"], 60);
if (result.exitCode !== 0) return { exists: false, name: pipelineRun, result: compactRuntimeCommand(result) };
const [status = "", reason = "", message = "", createdAt = ""] = result.stdout.split(/\r?\n/u);
return {
exists: true,
name: pipelineRun,
status: status || null,
reason: reason || null,
message: message || null,
createdAt: createdAt || null,
result: compactRuntimeCommand(result),
};
}
export function sleepSync(ms: number): void {
const buffer = new SharedArrayBuffer(4);
Atomics.wait(new Int32Array(buffer), 0, 0, Math.max(0, ms));
}
export function syncNodeExternalPostgresSecrets(spec: HwlabRuntimeLaneSpec, dryRun: boolean, timeoutSeconds: number): Record<string, unknown> | null {
const pg = hwlabRuntimeActiveExternalPostgres(spec);
if (pg === undefined) return null;
const secretRoot = externalPostgresSecretSourceRoot(spec);
const cloudApi = readSecretSourceValue(secretRoot, pg.cloudApi.sourceRef, pg.cloudApi.envKey);
const openfga = readSecretSourceValue(secretRoot, pg.openfga.sourceRef, pg.openfga.envKey);
const missing = [
...(cloudApi.ok ? [] : [`${pg.cloudApi.sourceRef}:${pg.cloudApi.envKey}`]),
...(openfga.ok ? [] : [`${pg.openfga.sourceRef}:${pg.openfga.envKey}`]),
];
if (missing.length > 0) {
if (dryRun) {
return {
ok: true,
mode: "dry-run",
mutation: false,
skipped: true,
reason: "external-postgres-secret-source-missing",
missing,
sourceRoot: secretRoot,
valuesPrinted: false,
next: { platformDbApply: `bun scripts/cli.ts platform-db postgres apply --config ${pg.configRef} --confirm` },
};
}
return {
ok: false,
mode: dryRun ? "dry-run" : "confirmed-secret-sync",
mutation: false,
degradedReason: "external-postgres-secret-source-missing",
missing,
sourceRoot: secretRoot,
valuesPrinted: false,
next: { platformDbApply: `bun scripts/cli.ts platform-db postgres apply --config ${pg.configRef} --confirm` },
};
}
const setup = runNodeK3sScript(spec, externalPostgresSecretSetupScript(spec, dryRun), timeoutSeconds);
if (!isCommandSuccess(setup)) {
return {
ok: false,
mode: dryRun ? "dry-run" : "confirmed-secret-sync",
mutation: false,
phase: "namespace-authn-setup",
setup: compactRuntimeCommand(setup),
valuesPrinted: false,
};
}
const manifest = {
apiVersion: "v1",
kind: "List",
items: [
{
apiVersion: "v1",
kind: "Secret",
metadata: { name: pg.cloudApi.secretName, namespace: spec.runtimeNamespace },
type: "Opaque",
stringData: { [pg.cloudApi.secretKey]: cloudApi.value },
},
{
apiVersion: "v1",
kind: "Secret",
metadata: { name: pg.openfga.secretName, namespace: spec.runtimeNamespace },
type: "Opaque",
stringData: { [pg.openfga.secretKey]: openfga.value },
},
],
};
const applyScript = [
"set -eu",
[
"kubectl",
"apply",
"--server-side",
"--force-conflicts",
"--field-manager=unidesk-hwlab-node-external-postgres-secret",
...(dryRun ? ["--dry-run=server"] : []),
"-f",
"-",
].map(shellQuote).join(" "),
].join("\n");
const apply = runNodeK3sScript(spec, applyScript, timeoutSeconds, JSON.stringify(manifest));
return {
ok: isCommandSuccess(apply),
mode: dryRun ? "dry-run" : "confirmed-secret-sync",
mutation: !dryRun && isCommandSuccess(apply),
namespace: spec.runtimeNamespace,
sourceRoot: secretRoot,
secrets: [
{ name: pg.cloudApi.secretName, key: pg.cloudApi.secretKey, sourceRef: pg.cloudApi.sourceRef, envKey: pg.cloudApi.envKey },
{ name: pg.openfga.secretName, key: pg.openfga.secretKey, sourceRef: pg.openfga.sourceRef, envKey: pg.openfga.envKey, authnKey: pg.openfga.authnKey ?? null },
],
setup: compactRuntimeCommand(setup),
apply: compactRuntimeCommand(apply),
valuesPrinted: false,
};
}
export function syncNodeLocalPostgresBootstrapSecret(spec: HwlabRuntimeLaneSpec, dryRun: boolean, timeoutSeconds: number): Record<string, unknown> | null {
const pg = spec.runtimeStore?.postgres;
if (pg?.mode !== "local-k3s") return null;
const secretName = pg.secretName ?? `${spec.runtimeNamespace}-postgres`;
const sourceRef = pg.adminPasswordSourceRef ?? "";
const sourceKey = pg.adminPasswordSourceKey ?? "";
if (sourceRef.length === 0 || sourceKey.length === 0) {
return {
ok: false,
mode: dryRun ? "dry-run" : "confirmed-secret-sync",
mutation: false,
namespace: spec.runtimeNamespace,
secretName,
secretKey: "POSTGRES_PASSWORD",
degradedReason: "local-postgres-secret-source-not-configured",
valuesPrinted: false,
next: { fixYaml: `set runtimeStore.postgres.adminPasswordSourceRef/adminPasswordSourceKey for node=${spec.nodeId} lane=${spec.lane}` },
};
}
const material = readLocalPostgresPasswordMaterial({ sourceRef, sourceKey, dryRun });
if (material.ok !== true) {
return {
ok: false,
mode: dryRun ? "dry-run" : "confirmed-secret-sync",
mutation: false,
namespace: spec.runtimeNamespace,
secretName,
secretKey: "POSTGRES_PASSWORD",
source: material,
degradedReason: material.error ?? "local-postgres-secret-source-missing",
valuesPrinted: false,
next: { createSource: `create ${material.sourcePath ?? join(".state", "secrets", sourceRef)} with ${sourceKey}=<redacted>` },
};
}
const result = runNodeK3sScript(spec, localPostgresBootstrapSecretScript(spec, secretName, sourceRef, sourceKey, dryRun, material.fingerprint), timeoutSeconds, material.value ?? "");
const fields = keyValueLinesFromText(statusText(result));
const afterBytes = numericField(fields.afterPasswordBytes) ?? 0;
const ok = isCommandSuccess(result) && fields.afterSecretExists === "yes" && afterBytes > 0;
return {
ok,
mode: dryRun ? "dry-run" : "confirmed-secret-sync",
mutation: !dryRun && fields.mutation === "true",
namespace: spec.runtimeNamespace,
secretName,
secretKey: "POSTGRES_PASSWORD",
source: {
ok: material.ok,
sourceRef,
sourceKey,
sourcePath: material.sourcePath === null ? null : displayRepoPath(material.sourcePath),
generated: material.generated,
fingerprint: material.fingerprint,
valueBytes: material.value?.length ?? 0,
valuesPrinted: false,
},
before: {
namespaceExists: fields.beforeNamespaceExists === "yes",
secretExists: fields.beforeSecretExists === "yes",
passwordBytes: numericField(fields.beforePasswordBytes),
fingerprint: fields.beforePasswordFingerprint || null,
},
after: {
namespaceExists: fields.afterNamespaceExists === "yes",
secretExists: fields.afterSecretExists === "yes",
passwordBytes: numericField(fields.afterPasswordBytes),
fingerprint: fields.afterPasswordFingerprint || null,
},
action: fields.action || null,
applyExitCode: numericField(fields.applyExitCode),
namespaceCreateExitCode: numericField(fields.namespaceCreateExitCode),
degradedReason: ok ? undefined : fields.action === "source-mismatch" ? "local-postgres-secret-source-mismatch" : "local-postgres-secret-apply-failed",
result: compactRuntimeCommand(result),
valuesPrinted: false,
};
}
export function readLocalPostgresPasswordMaterial(input: { sourceRef: string; sourceKey: string; dryRun: boolean }): {
ok: boolean;
sourceRef: string;
sourceKey: string;
sourcePath: string | null;
checkedPaths: string[];
value: string | null;
fingerprint: string | null;
generated: boolean;
error?: string;
} {
const checkedPaths = localSecretSourcePaths(input.sourceRef);
const existingPath = checkedPaths.find((candidate) => existsSync(candidate)) ?? null;
const sourcePath = existingPath ?? checkedPaths[0] ?? null;
if (sourcePath === null) {
return { ok: false, sourceRef: input.sourceRef, sourceKey: input.sourceKey, sourcePath: null, checkedPaths, value: null, fingerprint: null, generated: false, error: "local-postgres-secret-source-path-unresolved" };
}
if (existingPath === null && input.dryRun) {
return { ok: false, sourceRef: input.sourceRef, sourceKey: input.sourceKey, sourcePath, checkedPaths, value: null, fingerprint: null, generated: false, error: "local-postgres-secret-source-missing" };
}
if (existingPath === null) {
const value = randomBytes(32).toString("base64url");
mkdirSync(dirname(sourcePath), { recursive: true });
writeFileSync(sourcePath, `${input.sourceKey}=${value}\n`, { mode: 0o600 });
return { ok: true, sourceRef: input.sourceRef, sourceKey: input.sourceKey, sourcePath, checkedPaths, value, fingerprint: shortSecretFingerprint(value), generated: true };
}
const text = readFileSync(existingPath, "utf8");
const values = parseEnvFile(text);
const existingValue = values[input.sourceKey];
if (existingValue !== undefined && existingValue.length > 0) {
return { ok: true, sourceRef: input.sourceRef, sourceKey: input.sourceKey, sourcePath: existingPath, checkedPaths, value: existingValue, fingerprint: shortSecretFingerprint(existingValue), generated: false };
}
if (input.dryRun) {
return { ok: false, sourceRef: input.sourceRef, sourceKey: input.sourceKey, sourcePath: existingPath, checkedPaths, value: null, fingerprint: null, generated: false, error: "local-postgres-secret-source-key-missing" };
}
const value = randomBytes(32).toString("base64url");
const prefix = text.length === 0 || text.endsWith("\n") ? "" : "\n";
writeFileSync(existingPath, `${text}${prefix}${input.sourceKey}=${value}\n`, { mode: 0o600 });
return { ok: true, sourceRef: input.sourceRef, sourceKey: input.sourceKey, sourcePath: existingPath, checkedPaths, value, fingerprint: shortSecretFingerprint(value), generated: true };
}
export function localSecretSourcePaths(sourceRef: string): string[] {
const marker = "/.worktree/";
const index = repoRoot.indexOf(marker);
const paths = index >= 0
? [
join(repoRoot.slice(0, index), ".state", "secrets", sourceRef),
join(repoRoot, ".state", "secrets", sourceRef),
]
: [join(repoRoot, ".state", "secrets", sourceRef)];
return [...new Set(paths)];
}
export function shortSecretFingerprint(value: string): string {
return `sha256:${createHash("sha256").update(value).digest("hex").slice(0, 16)}`;
}
export function localPostgresBootstrapSecretScript(spec: HwlabRuntimeLaneSpec, secretName: string, sourceRef: string, sourceKey: string, dryRun: boolean, sourceFingerprint: string | null): string {
return [
"set +e",
`namespace=${shellQuote(spec.runtimeNamespace)}`,
`secret=${shellQuote(secretName)}`,
"secret_key=POSTGRES_PASSWORD",
`source_ref=${shellQuote(sourceRef)}`,
`source_key=${shellQuote(sourceKey)}`,
`source_fingerprint=${shellQuote(sourceFingerprint ?? "")}`,
`dry_run=${shellQuote(dryRun ? "true" : "false")}`,
"field_manager=unidesk-hwlab-node-local-postgres-secret",
"password=$(cat)",
"password_bytes=$(printf '%s' \"$password\" | wc -c | tr -d ' ')",
"exists_flag() { kubectl -n \"$namespace\" get \"$1\" \"$2\" >/dev/null 2>&1 && printf yes || printf no; }",
"namespace_exists_flag() { kubectl get namespace \"$namespace\" >/dev/null 2>&1 && printf yes || printf no; }",
"secret_b64_key() { kubectl -n \"$namespace\" get secret \"$secret\" -o \"go-template={{ index .data \\\"$secret_key\\\" }}\" 2>/dev/null || true; }",
"decoded_length() { if [ -n \"$1\" ]; then printf '%s' \"$1\" | base64 -d 2>/dev/null | wc -c | tr -d ' '; else printf '0'; fi; }",
"decoded_fingerprint() { if [ -n \"$1\" ]; then printf '%s' \"$1\" | base64 -d 2>/dev/null | sha256sum | awk '{print \"sha256:\" substr($1,1,16)}'; fi; }",
"before_namespace_exists=$(namespace_exists_flag)",
"before_secret_exists=no",
"before_b64=",
"if [ \"$before_namespace_exists\" = yes ]; then before_secret_exists=$(exists_flag secret \"$secret\"); before_b64=$(secret_b64_key); fi",
"before_password_bytes=$(decoded_length \"$before_b64\")",
"before_password_fingerprint=$(decoded_fingerprint \"$before_b64\")",
"after_namespace_exists=$before_namespace_exists",
"after_secret_exists=$before_secret_exists",
"after_password_bytes=$before_password_bytes",
"after_password_fingerprint=$before_password_fingerprint",
"action=observed",
"mutation=false",
"namespace_create_exit=",
"apply_exit=",
"if [ \"$dry_run\" = true ]; then",
" if [ \"$before_secret_exists\" != yes ] || [ \"$before_password_bytes\" -le 0 ]; then action=would-ensure; else action=kept; fi",
"elif [ \"$password_bytes\" -le 0 ]; then",
" action=source-empty",
" apply_exit=42",
"elif [ \"$before_password_bytes\" -gt 0 ] && [ -n \"$source_fingerprint\" ] && [ \"$before_password_fingerprint\" != \"$source_fingerprint\" ]; then",
" action=source-mismatch",
" apply_exit=47",
"else",
" if [ \"$before_namespace_exists\" != yes ]; then",
" kubectl create namespace \"$namespace\" >/tmp/hwlab-local-postgres-namespace.out 2>/tmp/hwlab-local-postgres-namespace.err",
" namespace_create_exit=$?",
" else",
" namespace_create_exit=0",
" fi",
" if [ \"$namespace_create_exit\" = 0 ]; then",
" tmp=$(mktemp /tmp/hwlab-local-postgres-secret.XXXXXX.yaml)",
" password_b64=$(printf '%s' \"$password\" | base64 | tr -d '\\n')",
" cat >\"$tmp\" <<EOF_SECRET",
"apiVersion: v1",
"kind: Secret",
"metadata:",
" name: $secret",
" namespace: $namespace",
" labels:",
" app.kubernetes.io/part-of: hwlab",
" app.kubernetes.io/managed-by: unidesk",
" annotations:",
" hwlab.pikastech.local/secret-source-ref: $source_ref",
" hwlab.pikastech.local/secret-source-key: $source_key",
" hwlab.pikastech.local/secret-source-fingerprint: $source_fingerprint",
"type: Opaque",
"data:",
" $secret_key: $password_b64",
"EOF_SECRET",
" kubectl apply --server-side --force-conflicts --field-manager=\"$field_manager\" -f \"$tmp\" >/tmp/hwlab-local-postgres-secret.apply.out 2>/tmp/hwlab-local-postgres-secret.apply.err",
" apply_exit=$?",
" rm -f \"$tmp\"",
" if [ \"$apply_exit\" = 0 ]; then action=ensured; mutation=true; else action=apply-failed; fi",
" else",
" action=namespace-create-failed",
" apply_exit=$namespace_create_exit",
" fi",
"fi",
"after_namespace_exists=$(namespace_exists_flag)",
"if [ \"$after_namespace_exists\" = yes ]; then after_secret_exists=$(exists_flag secret \"$secret\"); after_b64=$(secret_b64_key); else after_secret_exists=no; after_b64=; fi",
"after_password_bytes=$(decoded_length \"$after_b64\")",
"after_password_fingerprint=$(decoded_fingerprint \"$after_b64\")",
"printf 'namespace\\t%s\\n' \"$namespace\"",
"printf 'secret\\t%s\\n' \"$secret\"",
"printf 'key\\t%s\\n' \"$secret_key\"",
"printf 'sourceRef\\t%s\\n' \"$source_ref\"",
"printf 'sourceKey\\t%s\\n' \"$source_key\"",
"printf 'dryRun\\t%s\\n' \"$dry_run\"",
"printf 'action\\t%s\\n' \"$action\"",
"printf 'mutation\\t%s\\n' \"$mutation\"",
"printf 'beforeNamespaceExists\\t%s\\n' \"$before_namespace_exists\"",
"printf 'beforeSecretExists\\t%s\\n' \"$before_secret_exists\"",
"printf 'beforePasswordBytes\\t%s\\n' \"$before_password_bytes\"",
"printf 'beforePasswordFingerprint\\t%s\\n' \"$before_password_fingerprint\"",
"printf 'afterNamespaceExists\\t%s\\n' \"$after_namespace_exists\"",
"printf 'afterSecretExists\\t%s\\n' \"$after_secret_exists\"",
"printf 'afterPasswordBytes\\t%s\\n' \"$after_password_bytes\"",
"printf 'afterPasswordFingerprint\\t%s\\n' \"$after_password_fingerprint\"",
"printf 'namespaceCreateExitCode\\t%s\\n' \"$namespace_create_exit\"",
"printf 'applyExitCode\\t%s\\n' \"$apply_exit\"",
"password=",
"if [ -n \"$apply_exit\" ] && [ \"$apply_exit\" != 0 ]; then exit \"$apply_exit\"; fi",
"if [ \"$after_secret_exists\" != yes ] || [ \"$after_password_bytes\" -le 0 ]; then exit 48; fi",
].join("\n");
}
export function nodeRuntimeBaseImageStatus(spec: HwlabRuntimeLaneSpec, timeoutSeconds: number): Record<string, unknown> {
const source = spec.baseImageSource ?? "";
const script = [
"set -eu",
`target=${shellQuote(spec.baseImage)}`,
`source=${shellQuote(source)}`,
"repo_tag=${target#*/}",
"repo=${repo_tag%:*}",
"tag=${repo_tag##*:}",
"if [ \"$repo\" = \"$repo_tag\" ]; then tag=latest; fi",
"registry_url=\"http://127.0.0.1:5000/v2/$repo/tags/list\"",
"registry_present=false",
"if curl -fsS \"$registry_url\" 2>/dev/null | grep -q '\"'\"$tag\"'\"'; then registry_present=true; fi",
"target_present=false",
"if docker image inspect \"$target\" >/dev/null 2>&1; then target_present=true; fi",
"source_present=false",
"if [ -n \"$source\" ] && docker image inspect \"$source\" >/dev/null 2>&1; then source_present=true; fi",
"printf 'target\\t%s\\n' \"$target\"",
"printf 'source\\t%s\\n' \"$source\"",
"printf 'sourceConfigured\\t%s\\n' \"$([ -n \"$source\" ] && printf true || printf false)\"",
"printf 'registryUrl\\t%s\\n' \"$registry_url\"",
"printf 'registryTagPresent\\t%s\\n' \"$registry_present\"",
"printf 'targetImagePresent\\t%s\\n' \"$target_present\"",
"printf 'sourceImagePresent\\t%s\\n' \"$source_present\"",
].join("\n");
const result = runNodeHostScript(spec, script, Math.min(timeoutSeconds, 300));
const fields = keyValueLinesFromText(statusText(result));
const sourceConfigured = fields.sourceConfigured === "true";
const registryTagPresent = fields.registryTagPresent === "true";
return {
ok: isCommandSuccess(result) && sourceConfigured && registryTagPresent,
target: fields.target ?? spec.baseImage,
source: fields.source || null,
sourceConfigured,
registryUrl: fields.registryUrl ?? null,
registryTagPresent,
targetImagePresent: fields.targetImagePresent === "true",
sourceImagePresent: fields.sourceImagePresent === "true",
result: compactRuntimeCommand(result),
};
}
export function ensureNodeBaseImage(spec: HwlabRuntimeLaneSpec, dryRun: boolean, timeoutSeconds: number): Record<string, unknown> | null {
if (spec.baseImageSource === undefined) return null;
const script = [
"set -eu",
`target=${shellQuote(spec.baseImage)}`,
`source=${shellQuote(spec.baseImageSource)}`,
`dry_run=${shellQuote(dryRun ? "true" : "false")}`,
`pull_retries=${shellQuote(String(Math.max(1, spec.downloadProfile.docker.pullRetries)))}`,
"repo_tag=${target#*/}",
"repo=${repo_tag%:*}",
"tag=${repo_tag##*:}",
"if [ \"$repo\" = \"$repo_tag\" ]; then tag=latest; fi",
"registry_url=\"http://127.0.0.1:5000/v2/$repo/tags/list\"",
"present=false",
"if curl -fsS \"$registry_url\" 2>/dev/null | grep -q '\"'\"$tag\"'\"'; then present=true; fi",
"action=none",
"if [ \"$present\" = false ]; then",
" action=seed",
" if [ \"$dry_run\" = false ]; then",
" source_present_before=true",
" if ! docker image inspect \"$source\" >/dev/null 2>&1; then",
" source_present_before=false",
" attempt=1",
" pulled_source=false",
" while [ \"$attempt\" -le \"$pull_retries\" ]; do",
" pull_attempts=$attempt",
" if docker pull \"$source\" >/tmp/hwlab-node-base-image-pull.out 2>&1; then pulled_source=true; break; fi",
" attempt=$((attempt + 1))",
" sleep 2",
" done",
" if [ \"$pulled_source\" != true ]; then cat /tmp/hwlab-node-base-image-pull.out >&2 2>/dev/null || true; fi",
" fi",
" docker image inspect \"$source\" >/dev/null",
" docker tag \"$source\" \"$target\"",
" docker push \"$target\" >/tmp/hwlab-node-base-image-push.out",
" fi",
"fi",
"after=false",
"if curl -fsS \"$registry_url\" 2>/dev/null | grep -q '\"'\"$tag\"'\"'; then after=true; fi",
"printf 'target\\t%s\\n' \"$target\"",
"printf 'source\\t%s\\n' \"$source\"",
"printf 'dryRun\\t%s\\n' \"$dry_run\"",
"printf 'presentBefore\\t%s\\n' \"$present\"",
"printf 'presentAfter\\t%s\\n' \"$after\"",
"printf 'action\\t%s\\n' \"$action\"",
"printf 'pullRetries\\t%s\\n' \"$pull_retries\"",
"printf 'sourcePresentBefore\\t%s\\n' \"${source_present_before:-unknown}\"",
"printf 'pulledSource\\t%s\\n' \"${pulled_source:-false}\"",
"printf 'pullAttempts\\t%s\\n' \"${pull_attempts:-0}\"",
"if [ \"$dry_run\" = true ] || [ \"$after\" = true ]; then exit 0; fi",
"exit 1",
].join("\n");
const result = runNodeHostScript(spec, script, Math.min(timeoutSeconds, 300));
const fields = keyValueLinesFromText(statusText(result));
return {
ok: isCommandSuccess(result),
dryRun,
target: fields.target ?? spec.baseImage,
source: fields.source ?? spec.baseImageSource,
presentBefore: fields.presentBefore === "true",
presentAfter: fields.presentAfter === "true",
action: fields.action ?? null,
pullRetries: numericField(fields.pullRetries),
sourcePresentBefore: fields.sourcePresentBefore ?? null,
pulledSource: fields.pulledSource === "true",
pullAttempts: numericField(fields.pullAttempts),
result: compactRuntimeCommand(result),
};
}
export function externalPostgresSecretSetupScript(spec: HwlabRuntimeLaneSpec, dryRun: boolean): string {
const pg = hwlabRuntimeActiveExternalPostgres(spec);
if (pg === undefined) return "true";
const authnKey = pg.openfga.authnKey ?? "authn-preshared-key";
return [
"set -eu",
`namespace=${shellQuote(spec.runtimeNamespace)}`,
`secret=${shellQuote(pg.openfga.secretName)}`,
`authn_key=${shellQuote(authnKey)}`,
`dry_run=${shellQuote(dryRun ? "true" : "false")}`,
"namespace_apply_args=\"--server-side --field-manager=unidesk-hwlab-node-runtime-namespace\"",
"if [ \"$dry_run\" = true ]; then namespace_apply_args=\"$namespace_apply_args --dry-run=server\"; fi",
"kubectl create namespace \"$namespace\" --dry-run=client -o yaml | kubectl apply $namespace_apply_args -f -",
"authn_present=$(kubectl -n \"$namespace\" get secret \"$secret\" -o \"go-template={{ index .data \\\"$authn_key\\\" }}\" 2>/dev/null | wc -c | tr -d ' ')",
"if [ \"${authn_present:-0}\" -gt 0 ]; then",
" action=kept",
"elif [ \"$dry_run\" = true ]; then",
" action=would-generate-authn-key",
"else",
" if command -v openssl >/dev/null 2>&1; then authn_value=$(openssl rand -base64 48); else authn_value=$(head -c 48 /dev/urandom | base64); fi",
" kubectl -n \"$namespace\" create secret generic \"$secret\" --from-literal=\"$authn_key=$authn_value\" --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=unidesk-hwlab-node-openfga-authn -f - >/dev/null",
" authn_value=",
" action=generated-authn-key",
"fi",
"printf 'namespace\\t%s\\n' \"$namespace\"",
"printf 'secret\\t%s\\n' \"$secret\"",
"printf 'authnKey\\t%s\\n' \"$authn_key\"",
"printf 'action\\t%s\\n' \"$action\"",
"printf 'dryRun\\t%s\\n' \"$dry_run\"",
"printf 'valuesPrinted\\tfalse\\n'",
].join("\n");
}
export function externalPostgresSecretSourceRoot(spec: HwlabRuntimeLaneSpec): string {
const configRef = spec.externalPostgres?.configRef;
if (configRef === undefined) return join(repoRoot, ".state", "secrets");
const configPath = rootPath(configRef);
const parsed = Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${configRef} must be a YAML object`);
const secrets = (parsed as Record<string, unknown>).secrets;
if (typeof secrets !== "object" || secrets === null || Array.isArray(secrets)) throw new Error(`${configRef}.secrets must be an object`);
const root = (secrets as Record<string, unknown>).root;
if (typeof root !== "string" || root.length === 0) throw new Error(`${configRef}.secrets.root must be a non-empty string`);
return root.startsWith("/") ? root : rootPath(root);
}
export function readSecretSourceValue(secretRoot: string, sourceRef: string, key: string): { ok: true; value: string; sourcePath: string } | { ok: false; sourcePath: string } {
const sourcePath = join(secretRoot, sourceRef);
if (!existsSync(sourcePath)) return { ok: false, sourcePath };
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
const value = values[key];
if (value === undefined || value.length === 0) return { ok: false, sourcePath };
return { ok: true, value, sourcePath };
}
export function secretSourcePaths(sourceRef: string): string[] {
const paths = [join(repoRoot, ".state", "secrets", sourceRef)];
const marker = "/.worktree/";
const index = repoRoot.indexOf(marker);
if (index >= 0) paths.push(join(repoRoot.slice(0, index), ".state", "secrets", sourceRef));
return [...new Set(paths)];
}
export function displayRepoPath(path: string): string {
const normalizedRoot = repoRoot.replace(/\/+$/u, "");
if (path === normalizedRoot) return ".";
if (path.startsWith(`${normalizedRoot}/`)) return path.slice(normalizedRoot.length + 1);
const marker = "/.worktree/";
const index = normalizedRoot.indexOf(marker);
if (index >= 0) {
const mainRoot = normalizedRoot.slice(0, index);
if (path === mainRoot) return ".";
if (path.startsWith(`${mainRoot}/`)) return path.slice(mainRoot.length + 1);
}
return path;
}
export function hwlabPasswordHash(password: string): string {
const salt = randomBytes(16).toString("hex");
return `sha256:${salt}:${createHash("sha256").update(`${salt}:${password}`).digest("hex")}`;
}
export function readBootstrapAdminSecretMaterial(spec: RuntimeSecretSpec): BootstrapAdminSecretMaterial {
const sourceRef = spec.bootstrapAdminPasswordSourceRef;
const sourceKey = spec.bootstrapAdminPasswordSourceKey;
if (sourceRef === undefined || sourceKey === undefined || spec.bootstrapAdminPasswordHashTransform === undefined) {
return { ok: false, sourceRef: sourceRef ?? null, sourceKey: sourceKey ?? null, sourcePath: null, sourcePresent: false, sourceFingerprint: null, passwordHash: null, error: "bootstrap-admin-yaml-source-missing" };
}
const paths = secretSourcePaths(sourceRef);
const sourcePath = paths.find((candidate) => existsSync(candidate)) ?? paths[0] ?? join(repoRoot, ".state", "secrets", sourceRef);
if (!existsSync(sourcePath)) {
return { ok: false, sourceRef, sourceKey, sourcePath, sourcePresent: false, sourceFingerprint: null, passwordHash: null, error: "secret-source-missing" };
}
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
const password = values[sourceKey];
if (password === undefined || password.length === 0) {
return { ok: false, sourceRef, sourceKey, sourcePath, sourcePresent: true, sourceFingerprint: null, passwordHash: null, error: "secret-key-missing" };
}
return {
ok: true,
sourceRef,
sourceKey,
sourcePath,
sourcePresent: true,
sourceFingerprint: `sha256:${createHash("sha256").update(password).digest("hex").slice(0, 16)}`,
passwordHash: hwlabPasswordHash(password),
error: null,
};
}
export function readBootstrapAdminPasswordMaterial(spec: RuntimeSecretSpec): BootstrapAdminPasswordMaterial {
const sourceRef = spec.bootstrapAdminPasswordSourceRef;
const sourceKey = spec.bootstrapAdminPasswordSourceKey;
if (sourceRef === undefined || sourceKey === undefined || spec.bootstrapAdminPasswordHashTransform === undefined) {
return { ok: false, sourceRef: sourceRef ?? null, sourceKey: sourceKey ?? null, sourcePath: null, sourcePresent: false, sourceFingerprint: null, password: null, error: "bootstrap-admin-yaml-source-missing" };
}
const paths = secretSourcePaths(sourceRef);
const sourcePath = paths.find((candidate) => existsSync(candidate)) ?? paths[0] ?? join(repoRoot, ".state", "secrets", sourceRef);
if (!existsSync(sourcePath)) {
return { ok: false, sourceRef, sourceKey, sourcePath, sourcePresent: false, sourceFingerprint: null, password: null, error: "secret-source-missing" };
}
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
const password = values[sourceKey];
if (password === undefined || password.length === 0) {
return { ok: false, sourceRef, sourceKey, sourcePath, sourcePresent: true, sourceFingerprint: null, password: null, error: "secret-key-missing" };
}
return {
ok: true,
sourceRef,
sourceKey,
sourcePath,
sourcePresent: true,
sourceFingerprint: `sha256:${createHash("sha256").update(password).digest("hex").slice(0, 16)}`,
password,
error: null,
};
}
export function parseEnvFile(text: string): Record<string, string> {
const values: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line.length === 0 || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
const rawValue = line.slice(eq + 1).trim();
values[key] = rawValue.startsWith("'") && rawValue.endsWith("'")
? rawValue.slice(1, -1).replace(/'"'"'/gu, "'")
: rawValue.startsWith("\"") && rawValue.endsWith("\"")
? rawValue.slice(1, -1)
: rawValue;
}
return values;
}
export function isLocalPostgresObject(name: string, spec: HwlabRuntimeLaneSpec): boolean {
if (!/postgres|pgsql|pgdata/iu.test(name)) return false;
const externalServiceName = spec.externalPostgres?.serviceName;
if (externalServiceName !== undefined && (name === `service/${externalServiceName}` || name === `svc/${externalServiceName}`)) return false;
return true;
}
export function externalPostgresBridgeStatus(spec: HwlabRuntimeLaneSpec, namespaceExists: boolean): Record<string, unknown> {
const pg = hwlabRuntimeActiveExternalPostgres(spec);
if (pg === undefined) return { required: false, ready: true };
if (!namespaceExists) return { required: true, ready: false, degradedReason: "runtime-namespace-missing" };
const runtimeAccess = pg.runtimeAccess ?? { endpointAddress: pg.endpointAddress, port: pg.port };
const endpointIsIpv4 = /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/u.test(runtimeAccess.endpointAddress);
const service = runNodeK3sArgs(spec, ["kubectl", "-n", spec.runtimeNamespace, "get", "service", pg.serviceName, "-o", "jsonpath={.spec.type}{\"\\n\"}{.spec.externalName}{\"\\n\"}{.spec.ports[0].port}{\"\\n\"}"], 60);
const [serviceType = "", externalName = "", servicePort = ""] = service.stdout.split(/\r?\n/u);
if (!endpointIsIpv4) {
const ready = service.exitCode === 0 && serviceType === "ExternalName" && externalName === runtimeAccess.endpointAddress && Number(servicePort) === runtimeAccess.port;
return {
required: true,
ready,
serviceName: pg.serviceName,
serviceType: serviceType || null,
externalName: externalName || null,
expectedExternalName: runtimeAccess.endpointAddress,
port: numericField(servicePort),
expectedPort: runtimeAccess.port,
providerEndpointAddress: pg.endpointAddress,
providerPort: pg.port,
runtimeAccess: pg.runtimeAccess ?? null,
service: compactRuntimeCommand(service),
};
}
const endpointSlice = runNodeK3sArgs(spec, ["kubectl", "-n", spec.runtimeNamespace, "get", "endpointslice", `${pg.serviceName}-host`, "-o", "jsonpath={.addressType}{\"\\n\"}{.endpoints[0].addresses[0]}{\"\\n\"}{.ports[0].port}{\"\\n\"}"], 60);
const [addressType = "", address = "", port = ""] = endpointSlice.stdout.split(/\r?\n/u);
return {
required: true,
ready: service.exitCode === 0 && endpointSlice.exitCode === 0 && serviceType !== "ExternalName" && address === runtimeAccess.endpointAddress && Number(port) === runtimeAccess.port,
serviceName: pg.serviceName,
serviceType: serviceType || null,
addressType: addressType || null,
endpointAddress: address || null,
expectedEndpointAddress: runtimeAccess.endpointAddress,
port: numericField(port),
expectedPort: runtimeAccess.port,
providerEndpointAddress: pg.endpointAddress,
providerPort: pg.port,
runtimeAccess: pg.runtimeAccess ?? null,
service: compactRuntimeCommand(service),
endpointSlice: compactRuntimeCommand(endpointSlice),
};
}
export function externalPostgresSecretStatus(spec: HwlabRuntimeLaneSpec, namespaceExists: boolean): Record<string, unknown> {
const pg = hwlabRuntimeActiveExternalPostgres(spec);
if (pg === undefined) return { required: false, ready: true };
if (!namespaceExists) return { required: true, ready: false, degradedReason: "runtime-namespace-missing" };
const cloudApi = secretKeyStatus(spec, pg.cloudApi.secretName, pg.cloudApi.secretKey);
const openfgaUri = secretKeyStatus(spec, pg.openfga.secretName, pg.openfga.secretKey);
const openfgaAuthn = secretKeyStatus(spec, pg.openfga.secretName, pg.openfga.authnKey ?? "authn-preshared-key");
return {
required: true,
ready: cloudApi.present && openfgaUri.present && openfgaAuthn.present,
valuesPrinted: false,
cloudApi,
openfga: {
datastoreUri: openfgaUri,
authnPresharedKey: openfgaAuthn,
},
};
}
export function nodeRuntimeGitMirrorTarget(spec: HwlabRuntimeLaneSpec): NodeRuntimeGitMirrorTargetSpec {
const configPath = rootPath(HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH);
const parsed = record(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown);
const nodes = record(parsed.nodes);
const node = record(nodes[spec.nodeId]);
const targets = Array.isArray(parsed.targets) ? parsed.targets : [];
const target = targets.map((item) => record(item)).find((item) => item.node === spec.nodeId && item.lane === spec.lane);
if (target === undefined) throw new Error(`no gitMirror target for node=${spec.nodeId} lane=${spec.lane} in ${HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH}`);
const gitMirror = record(target.gitMirror);
const gitMirrorEgressProxy = record(gitMirror.egressProxy);
const gitMirrorEgressMode = stringValue(gitMirrorEgressProxy.mode, "gitMirror.egressProxy.mode");
if (gitMirrorEgressMode !== "node-global" && gitMirrorEgressMode !== "direct") throw new Error(`gitMirror.egressProxy.mode must be node-global or direct for node=${spec.nodeId} lane=${spec.lane}`);
const nodeEgressProxy = gitMirrorEgressMode === "direct"
? { mode: "direct" as const, required: false as const }
: nodeRuntimeGitMirrorEgressProxySpec(record(node.egressProxy), `nodes.${spec.nodeId}.egressProxy`);
if (gitMirrorEgressMode === "node-global" && gitMirrorEgressProxy.required !== true) throw new Error(`gitMirror.egressProxy.required must be true for node=${spec.nodeId} lane=${spec.lane}`);
const githubTransport = nodeRuntimeGitMirrorGithubTransportSpec(record(gitMirror.githubTransport), "gitMirror.githubTransport");
const source = record(target.source);
const gitops = record(target.gitops);
const tekton = record(target.tekton);
const toolsImage = record(tekton.toolsImage);
const toolsImagePullPolicy = optionalStringValue(toolsImage.imagePullPolicy, "tekton.toolsImage.imagePullPolicy") ?? "IfNotPresent";
if (toolsImagePullPolicy !== "Always" && toolsImagePullPolicy !== "IfNotPresent" && toolsImagePullPolicy !== "Never") throw new Error("tekton.toolsImage.imagePullPolicy must be Always, IfNotPresent, or Never");
return {
id: stringValue(target.id, "target.id"),
node: stringValue(target.node, "target.node"),
lane: stringValue(target.lane, "target.lane"),
namespace: stringValue(gitMirror.namespace, "gitMirror.namespace"),
serviceReadName: stringValue(gitMirror.serviceReadName, "gitMirror.serviceReadName"),
serviceWriteName: stringValue(gitMirror.serviceWriteName, "gitMirror.serviceWriteName"),
cachePvcName: stringValue(gitMirror.cachePvcName, "gitMirror.cachePvcName"),
cachePvcStorage: stringValue(gitMirror.cachePvcStorage, "gitMirror.cachePvcStorage"),
cacheHostPath: optionalStringValue(gitMirror.cacheHostPath, "gitMirror.cacheHostPath"),
servicePort: positiveIntegerValue(gitMirror.servicePort, "gitMirror.servicePort"),
secretName: stringValue(gitMirror.secretName, "gitMirror.secretName"),
syncConfigMapName: stringValue(gitMirror.syncConfigMapName, "gitMirror.syncConfigMapName"),
syncJobPrefix: stringValue(gitMirror.syncJobPrefix, "gitMirror.syncJobPrefix"),
flushJobPrefix: stringValue(gitMirror.flushJobPrefix, "gitMirror.flushJobPrefix"),
toolsImage: stringValue(toolsImage.output, "tekton.toolsImage.output"),
toolsImagePullPolicy,
sourceRepository: stringValue(source.repository, "source.repository"),
sourceBranch: stringValue(source.branch, "source.branch"),
gitopsBranch: stringValue(gitops.branch, "gitops.branch"),
egressProxy: nodeEgressProxy,
githubTransport,
};
}
export function nodeRuntimeGitMirrorGithubTransportSpec(raw: Record<string, unknown>, path: string): NodeRuntimeGitMirrorGithubTransportSpec {
const mode = stringValue(raw.mode, `${path}.mode`);
if (mode === "ssh") return { mode };
if (mode !== "https") throw new Error(`${path}.mode must be ssh or https`);
return {
mode,
username: stringValue(raw.username, `${path}.username`),
tokenSecretName: stringValue(raw.tokenSecretName, `${path}.tokenSecretName`),
tokenSecretKey: stringValue(raw.tokenSecretKey, `${path}.tokenSecretKey`),
tokenSourceRef: stringValue(raw.tokenSourceRef, `${path}.tokenSourceRef`),
tokenSourceKey: stringValue(raw.tokenSourceKey, `${path}.tokenSourceKey`),
};
}
export function nodeRuntimeGitMirrorEgressProxySpec(raw: Record<string, unknown>, path: string): NodeRuntimeGitMirrorEgressProxySpec {
const mode = stringValue(raw.mode, `${path}.mode`);
if (mode !== "k8s-service-cluster-ip") throw new Error(`${path}.mode must be k8s-service-cluster-ip`);
const port = Number(raw.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${path}.port must be a TCP port`);
const sourceType = stringValue(raw.sourceType, `${path}.sourceType`);
if (sourceType !== "subscription-url") throw new Error(`${path}.sourceType must be subscription-url`);
const noProxyRaw = raw.noProxy;
if (!Array.isArray(noProxyRaw)) throw new Error(`${path}.noProxy must be an array`);
const noProxy = noProxyRaw.map((item, index) => stringValue(item, `${path}.noProxy[${index}]`));
return {
mode,
clientName: stringValue(raw.clientName, `${path}.clientName`),
namespace: stringValue(raw.namespace, `${path}.namespace`),
serviceName: stringValue(raw.serviceName, `${path}.serviceName`),
port,
sourceRef: stringValue(raw.sourceRef, `${path}.sourceRef`),
sourceKey: stringValue(raw.sourceKey, `${path}.sourceKey`),
sourceType,
noProxy,
};
}
export function secretKeyStatus(spec: HwlabRuntimeLaneSpec, secret: string, key: string): Record<string, unknown> {
const result = runNodeK3sArgs(spec, ["kubectl", "-n", spec.runtimeNamespace, "get", "secret", secret, "-o", `go-template={{ index .data ${JSON.stringify(key)} }}`], 60);
return {
secret,
key,
present: result.exitCode === 0 && result.stdout.trim().length > 0,
valueBytesBase64: result.stdout.trim().length,
exitCode: result.exitCode,
stderr: result.exitCode === 0 ? "" : result.stderr.trim().slice(0, 500),
valuesPrinted: false,
};
}
export function startNodeDelegatedJob(options: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
const commandArgs = [
"hwlab",
"nodes",
options.domain,
...stripOptions(options.originalArgs, ["--node", "--lane", "--confirm", "--dry-run", "--wait", "--timeout-seconds"]),
"--node",
options.node,
"--lane",
options.lane,
"--confirm",
"--timeout-seconds",
String(options.timeoutSeconds),
"--wait",
];
const command = ["bun", "scripts/cli.ts", ...commandArgs];
const job = startJob(
`hwlab_nodes_${options.lane}_${options.domain}_${options.action}`,
command,
`Run HWLAB ${options.lane} ${options.domain} ${options.action} for node ${options.node}`,
);
return {
ok: true,
command: `hwlab nodes ${options.domain} ${options.action} --node ${options.node} --lane ${options.lane}`,
node: options.node,
lane: options.lane,
mode: "async-job",
reason: "confirmed control-plane/mirror actions can spend tens of seconds on remote work; default is fire-and-forget to avoid silent blocking",
job,
statusCommand: `bun scripts/cli.ts job status ${job.id}`,
waitCommand: command.join(" "),
};
}
export function rewriteDelegatedNodeResult(value: unknown, scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
const rewritten = rewriteDelegatedNodeValue(value, scoped);
const result = typeof rewritten === "object" && rewritten !== null && !Array.isArray(rewritten) ? rewritten as Record<string, unknown> : { value: rewritten };
return {
...result,
node: scoped.node,
commandFamily: "hwlab nodes",
};
}
export function rewriteDelegatedNodeValue(value: unknown, scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): unknown {
if (typeof value === "string") return rewriteDelegatedNodeString(value, scoped);
if (Array.isArray(value)) return value.map((item) => rewriteDelegatedNodeValue(item, scoped));
if (typeof value !== "object" || value === null) return value;
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rewriteDelegatedNodeValue(item, scoped)]));
}
export function rewriteDelegatedNodeString(value: string, scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): string {
const replaceCommand = (text: string, domain: DelegatedNodeDomain) => {
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
return text
.replace(new RegExp(`bun scripts/cli\\.ts hwlab g14 ${escapedDomain} ([a-z-]+)`, "gu"), `bun scripts/cli.ts hwlab nodes ${domain} $1 --node ${scoped.node}`)
.replace(new RegExp(`hwlab g14 ${escapedDomain} ([a-z-]+)`, "gu"), `hwlab nodes ${domain} $1 --node ${scoped.node}`);
};
return replaceCommand(replaceCommand(value, "control-plane"), "git-mirror");
}
export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
const [actionRaw] = args;
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "observe" && actionRaw !== "sentinel") throw new Error("web-probe usage: run|script|observe|sentinel --node NODE --lane vNN [--url URL]");
if (actionRaw === "sentinel") return parseNodeWebProbeSentinelOptions(args.slice(1));
if (actionRaw === "observe") {
const normalized = normalizeNodeWebProbeObserveArgs(args.slice(1));
const explicitNode = optionValue(args, "--node");
const explicitLane = optionValue(args, "--lane");
const requestedId = normalized.id ?? optionValue(normalized.args, "--job-id") ?? null;
const indexed = requestedId === null
? null
: readWebObserveIndexEntry(requestedId) ?? (explicitNode === undefined || explicitLane === undefined ? discoverWebObserveIndexEntry(requestedId) : null);
const node = explicitNode ?? indexed?.node;
const lane = explicitLane ?? indexed?.lane;
if (node === undefined || lane === undefined) {
if (requestedId !== null) {
throw new Error(`web-probe observe observer-id-unknown: ${requestedId}; run observe start first, or pass --node/--lane with --job-id/--state-dir to re-index this observer`);
}
throw new Error("web-probe observe node-lane-required-for-start: observe start requires --node/--lane; status|command|stop|collect|analyze should pass an observer id");
}
assertNodeId(node);
assertLane(lane);
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const spec = hwlabRuntimeLaneSpecForNode(lane, node);
return parseNodeWebProbeObserveOptions(normalized.args, node, lane, spec, normalized.id, indexed);
}
const node = requiredOption(args, "--node");
assertNodeId(node);
const lane = requiredOption(args, "--lane");
assertLane(lane);
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const spec = hwlabRuntimeLaneSpecForNode(lane, node);
if (actionRaw === "script") {
assertKnownOptions(args.slice(1), new Set([
"--node",
"--lane",
"--url",
"--timeout-ms",
"--viewport",
"--browser-proxy-mode",
"--script-file",
"--command-timeout-seconds",
]), new Set([]));
const scriptFile = optionValue(args, "--script-file");
if (scriptFile === undefined && process.stdin.isTTY) {
throw new Error("web-probe script requires a stdin heredoc or --script-file <path>");
}
const scriptText = scriptFile === undefined ? readFileSync(0, "utf8") : readFileSync(scriptFile, "utf8");
if (scriptText.trim().length === 0) throw new Error("web-probe script received an empty script");
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 30000, 60000);
const commandTimeoutSeconds = positiveIntegerOption(args, "--command-timeout-seconds", Math.max(60, Math.ceil(timeoutMs / 1000) + 30), 3600);
return {
action: "script",
node,
lane,
url: optionValue(args, "--url") ?? nodeWebProbeDefaultUrl(spec),
timeoutMs,
viewport: optionValue(args, "--viewport") ?? "1440x900",
browserProxyMode: parseWebProbeBrowserProxyMode(optionValue(args, "--browser-proxy-mode") ?? spec.webProbe?.browserProxyMode),
commandTimeoutSeconds,
scriptText,
scriptSource: {
kind: scriptFile === undefined ? "stdin" : "file",
path: scriptFile ?? null,
byteCount: Buffer.byteLength(scriptText),
sha256: `sha256:${createHash("sha256").update(scriptText).digest("hex")}`,
},
};
}
assertKnownOptions(args.slice(1), new Set([
"--node",
"--lane",
"--url",
"--timeout-ms",
"--wait-after-submit-ms",
"--wait-messages-ms",
"--wait-agent-terminal-ms",
"--trace-sample-count",
"--trace-sample-interval-ms",
"--message",
"--conversation-id",
"--command-timeout-seconds",
]), new Set([
"--fresh-session",
"--no-cancel-running",
]));
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 30000, 60000);
const waitAfterSubmitMs = positiveIntegerOption(args, "--wait-after-submit-ms", 1500, 60000);
const waitMessagesMs = positiveIntegerOption(args, "--wait-messages-ms", 2500, 60000);
const waitAgentTerminalMs = positiveIntegerOption(args, "--wait-agent-terminal-ms", 0, 600000);
const message = optionValue(args, "--message") ?? null;
const hasMessage = message !== null;
const traceSampleCountDefault = hasMessage ? 2 : 0;
const traceSampleIntervalDefault = hasMessage ? 1500 : 0;
const traceSampleCount = positiveIntegerOption(args, "--trace-sample-count", traceSampleCountDefault, 200);
const traceSampleIntervalMs = positiveIntegerOption(args, "--trace-sample-interval-ms", traceSampleIntervalDefault, 60000);
const commandTimeoutAutoSeconds = nodeWebProbeAutoCommandTimeoutSeconds({
timeoutMs,
waitAfterSubmitMs,
waitMessagesMs,
waitAgentTerminalMs,
traceSampleCount,
traceSampleIntervalMs,
freshSession: args.includes("--fresh-session"),
hasMessage,
});
const commandTimeoutRaw = optionValue(args, "--command-timeout-seconds");
const commandTimeoutUserProvided = commandTimeoutRaw !== undefined;
const commandTimeoutSeconds = commandTimeoutUserProvided
? Math.max(positiveIntegerOption(args, "--command-timeout-seconds", commandTimeoutAutoSeconds, 3600), commandTimeoutAutoSeconds)
: commandTimeoutAutoSeconds;
return {
action: "run",
node,
lane,
url: optionValue(args, "--url") ?? nodeWebProbeDefaultUrl(spec),
timeoutMs,
waitAfterSubmitMs,
waitMessagesMs,
waitAgentTerminalMs,
traceSampleCount,
traceSampleIntervalMs,
message,
conversationId: optionValue(args, "--conversation-id") ?? null,
freshSession: args.includes("--fresh-session"),
cancelRunning: !args.includes("--no-cancel-running"),
commandTimeoutSeconds,
commandTimeoutAutoSeconds,
commandTimeoutUserProvided,
};
}