feat(web-probe): add multi-sentinel registry

This commit is contained in:
Codex
2026-06-26 12:42:04 +00:00
parent 7b3df965cc
commit 4e0f1cba21
25 changed files with 1038 additions and 140 deletions
+138 -46
View File
@@ -1,5 +1,6 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: YAML-first CI/CD, image, GitOps and Argo command plan for the web-probe sentinel.
import { createHash, randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
@@ -8,6 +9,7 @@ import { repoRoot, rootPath } from "./config";
import { runCommand, type CommandResult } from "./command";
import { startJob } from "./jobs";
import { webProbeSentinelConfigPlan, withWebProbeSentinelConfigRendered } from "./hwlab-node-web-sentinel-config";
import { requireSentinelIdForRegistry, resolveWebProbeSentinel } from "./hwlab-node-web-sentinel-resolver";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import type { RenderedCliResult } from "./output";
@@ -15,7 +17,7 @@ export type WebProbeSentinelConfigAction = "plan" | "status";
export type WebProbeSentinelImageAction = "status" | "build";
export type WebProbeSentinelControlPlaneAction = "plan" | "apply" | "status" | "trigger-current";
export type WebProbeSentinelMaintenanceAction = "status" | "start" | "stop";
export type WebProbeSentinelReportView = "summary" | "turn-summary" | "findings" | "trace-frame";
export type WebProbeSentinelReportView = "summary" | "turn-summary" | "findings" | "trace-frame" | "auth-session-switch-summary";
export type WebProbeSentinelOptions =
| {
@@ -23,6 +25,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelConfigAction;
readonly node: string;
readonly lane: string;
readonly sentinelId: string | null;
readonly dryRun: boolean;
}
| {
@@ -30,6 +33,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelImageAction;
readonly node: string;
readonly lane: string;
readonly sentinelId: string | null;
readonly dryRun: boolean;
readonly confirm: boolean;
readonly wait: boolean;
@@ -40,6 +44,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelControlPlaneAction;
readonly node: string;
readonly lane: string;
readonly sentinelId: string | null;
readonly dryRun: boolean;
readonly confirm: boolean;
readonly wait: boolean;
@@ -50,6 +55,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelMaintenanceAction;
readonly node: string;
readonly lane: string;
readonly sentinelId: string | null;
readonly dryRun: boolean;
readonly confirm: boolean;
readonly wait: boolean;
@@ -63,6 +69,7 @@ export type WebProbeSentinelOptions =
readonly action: "validate";
readonly node: string;
readonly lane: string;
readonly sentinelId: string | null;
readonly dryRun: boolean;
readonly confirm: boolean;
readonly wait: boolean;
@@ -74,6 +81,7 @@ export type WebProbeSentinelOptions =
readonly action: "report";
readonly node: string;
readonly lane: string;
readonly sentinelId: string | null;
readonly view: WebProbeSentinelReportView;
readonly runId: string | null;
readonly latest: boolean;
@@ -85,6 +93,8 @@ export type WebProbeSentinelOptions =
interface SentinelCicdState {
readonly spec: HwlabRuntimeLaneSpec;
readonly sentinelId: string;
readonly configRefs: Record<string, string>;
readonly configReady: boolean;
readonly runtime: Record<string, unknown>;
readonly cicd: Record<string, unknown>;
@@ -163,8 +173,9 @@ interface ChildCliResult {
const SPEC_REF = "PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel";
export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: WebProbeSentinelOptions): RenderedCliResult {
if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action));
const state = loadSentinelCicdState(spec, options.timeoutSeconds);
if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action, options.sentinelId));
requireSentinelIdForRegistry(spec, options.sentinelId, `web-probe sentinel ${options.kind}`);
const state = loadSentinelCicdState(spec, options.sentinelId, options.timeoutSeconds);
if (options.kind === "image") return runSentinelImage(state, options);
if (options.kind === "control-plane") return runSentinelControlPlane(state, options);
if (options.kind === "maintenance") return runSentinelMaintenance(state, options);
@@ -187,6 +198,7 @@ function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSen
command,
node: state.spec.nodeId,
lane: state.spec.lane,
sentinelId: state.sentinelId,
mode: options.action === "status" ? "status" : options.confirm ? "confirm" : "dry-run",
mutation: false,
specRef: SPEC_REF,
@@ -198,10 +210,10 @@ function runSentinelImage(state: SentinelCicdState, options: Extract<WebProbeSen
? registryReady ? null : { code: "sentinel-image-missing", reason: "expected sentinel image tag is not present in the node-local registry" }
: { code: "sentinel-source-mirror-not-ready", reason: "source.gitMirrorReadUrl does not expose the selected source commit yet" },
next: {
status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}`,
dryRun: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane} --dry-run`,
confirm: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane} --confirm`,
controlPlanePlan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${state.spec.nodeId} --lane ${state.spec.lane} --dry-run`,
status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
dryRun: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --dry-run`,
confirm: `bun scripts/cli.ts web-probe sentinel image build --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --confirm`,
controlPlanePlan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --dry-run`,
},
valuesRedacted: true,
};
@@ -223,6 +235,7 @@ function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebP
command,
node: state.spec.nodeId,
lane: state.spec.lane,
sentinelId: state.sentinelId,
mode: options.action === "status" ? "status" : options.confirm ? "confirm" : "dry-run",
mutation: false,
specRef: SPEC_REF,
@@ -262,13 +275,13 @@ function runSentinelControlPlane(state: SentinelCicdState, options: Extract<WebP
return rendered(result.ok, command, renderControlPlaneResult(result));
}
function loadSentinelCicdState(spec: HwlabRuntimeLaneSpec, timeoutSeconds: number): SentinelCicdState {
const sentinel = spec.observability.webProbe?.sentinel;
if (sentinel === undefined) throw new Error(`config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel is missing`);
const configPlan = webProbeSentinelConfigPlan(spec, "status");
function loadSentinelCicdState(spec: HwlabRuntimeLaneSpec, sentinelId: string | null, timeoutSeconds: number): SentinelCicdState {
const sentinel = resolveWebProbeSentinel(spec, sentinelId);
const configPlan = webProbeSentinelConfigPlan(spec, "status", sentinel.id);
const runtime = recordTarget(readConfigRefTarget(sentinel.configRefs.runtime), sentinel.configRefs.runtime);
const cicd = recordTarget(readConfigRefTarget(sentinel.configRefs.cicd), sentinel.configRefs.cicd);
const publicExposure = recordTarget(readConfigRefTarget(sentinel.configRefs.publicExposure), sentinel.configRefs.publicExposure);
const secrets = recordTarget(readConfigRefTarget(sentinel.configRefs.secrets), sentinel.configRefs.secrets);
const controlPlaneRef = stringField(cicd, "controlPlaneConfigRef");
const controlPlaneTarget = recordTarget(readConfigRefTarget(controlPlaneRef), controlPlaneRef);
const controlPlaneConfig = recordTarget(readConfigFile(configRefFile(controlPlaneRef)), configRefFile(controlPlaneRef));
@@ -276,10 +289,12 @@ function loadSentinelCicdState(spec: HwlabRuntimeLaneSpec, timeoutSeconds: numbe
const controlPlaneNode = recordTarget(valueAtPath(controlPlaneConfig, `nodes.${nodeId}`), `${configRefFile(controlPlaneRef)}#nodes.${nodeId}`);
const sourceHead = resolveSourceHead(cicd, timeoutSeconds);
const image = sentinelImagePlan(cicd, sourceHead);
const manifests = renderSentinelManifests(spec, runtime, cicd, publicExposure, image);
const manifests = renderSentinelManifests(spec, sentinel.id, runtime, cicd, publicExposure, secrets, image);
const manifestYaml = `${manifests.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`;
return {
spec,
sentinelId: sentinel.id,
configRefs: sentinel.configRefs,
configReady: configPlan.ok,
runtime,
cicd,
@@ -345,9 +360,11 @@ function sentinelDockerfile(baseImage: string, entrypoint: string): string {
function renderSentinelManifests(
spec: HwlabRuntimeLaneSpec,
sentinelId: string,
runtime: Record<string, unknown>,
cicd: Record<string, unknown>,
publicExposure: Record<string, unknown>,
secrets: Record<string, unknown>,
image: SentinelImagePlan,
): readonly Record<string, unknown>[] {
const namespace = stringAt(runtime, "namespace");
@@ -358,12 +375,14 @@ function renderSentinelManifests(
"unidesk.ai/spec-ref": "PJ2026-01060508",
"unidesk.ai/node": spec.nodeId,
"unidesk.ai/lane": spec.lane,
"unidesk.ai/web-probe-sentinel-id": sentinelId,
};
const deploymentName = stringAt(runtime, "deploymentName");
const serviceName = stringAt(runtime, "serviceName");
const servicePort = numberAt(runtime, "servicePort");
const pvcStorage = stringAt(runtime, "pvcStorage");
const stateRoot = stringAt(runtime, "stateRoot");
const sentinelEnv = sentinelContainerEnv(sentinelId, secrets);
return [
{
apiVersion: "v1",
@@ -385,7 +404,9 @@ function renderSentinelManifests(
specRef: SPEC_REF,
node: spec.nodeId,
lane: spec.lane,
sentinelId,
publicBaseUrl: stringAt(publicExposure, "publicBaseUrl"),
routePrefix: stringAtNullable(publicExposure, "routePrefix") ?? "/",
gitopsPath: stringAt(cicd, "gitopsPath"),
valuesRedacted: true,
}, null, 2),
@@ -411,6 +432,8 @@ function renderSentinelManifests(
spec.nodeId,
"--lane",
spec.lane,
"--sentinel",
sentinelId,
"--state-root",
stateRoot,
"--host",
@@ -418,6 +441,7 @@ function renderSentinelManifests(
"--port",
String(servicePort),
],
env: sentinelEnv,
ports: [{ name: "http", containerPort: servicePort }],
readinessProbe: { httpGet: { path: stringAt(runtime, "healthPath"), port: "http" } },
livenessProbe: { httpGet: { path: stringAt(runtime, "healthPath"), port: "http" } },
@@ -492,6 +516,34 @@ function renderSentinelManifests(
];
}
function sentinelContainerEnv(sentinelId: string, secrets: Record<string, unknown>): readonly Record<string, unknown>[] {
const env: Record<string, unknown>[] = [{ name: "UNIDESK_WEB_PROBE_SENTINEL_ID", value: sentinelId }];
for (const runtimeSecret of arrayAt(secrets, "runtimeSecrets").map(record)) {
const secretName = stringAtNullable(runtimeSecret, "name");
if (secretName === null) continue;
for (const item of arrayAt(runtimeSecret, "data").map(record)) {
const targetKey = stringAtNullable(item, "targetKey");
const sourcePurpose = stringAtNullable(item, "sourcePurpose");
const envName = sourcePurpose === null || targetKey === null ? null : accountSecretEnvName(sourcePurpose, targetKey);
if (envName === null) continue;
env.push({ name: envName, valueFrom: { secretKeyRef: { name: secretName, key: targetKey } } });
}
}
return env;
}
function accountSecretEnvName(sourcePurpose: string, targetKey: string): string | null {
if (!/^account-[a-z0-9-]+$/u.test(sourcePurpose) || !targetKey.endsWith(".json")) return null;
const segment = sourcePurpose.toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, "");
return segment.length === 0 ? null : `HWLAB_WEB_${segment}_JSON`;
}
function normalizeRoutePrefix(value: string | null): string {
if (value === null || value.trim() === "" || value.trim() === "/") return "/";
const prefixed = value.trim().startsWith("/") ? value.trim() : `/${value.trim()}`;
return prefixed.replace(/\/+$/u, "") || "/";
}
function probeImageRegistry(state: SentinelCicdState, timeoutSeconds: number): Record<string, unknown> {
const endpoint = stringAt(state.controlPlaneNode, "registry.endpoint");
const repoTag = state.image.ref.replace(`${endpoint}/`, "");
@@ -555,8 +607,8 @@ function runSentinelImageBuildConfirmed(state: SentinelCicdState, options: Extra
? { code: "sentinel-image-publish-failed", reason: "remote image publish job failed before registry validation" }
: { code: "sentinel-image-registry-missing", reason: "image publish completed but expected registry tag is not visible" },
next: {
status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}`,
controlPlaneTrigger: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${state.spec.nodeId} --lane ${state.spec.lane} --confirm`,
status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
controlPlaneTrigger: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --confirm`,
},
valuesRedacted: true,
};
@@ -663,9 +715,9 @@ function runSentinelControlPlaneConfirmed(state: SentinelCicdState, options: Ext
function renderAsyncSentinelJob(state: SentinelCicdState, domain: "image" | "control-plane", action: string, timeoutSeconds: number): RenderedCliResult {
const args = domain === "image"
? ["web-probe", "sentinel", "image", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)]
: ["web-probe", "sentinel", "control-plane", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)];
const job = startJob(`hwlab_nodes_${state.spec.lane}_web_probe_sentinel_${domain}_${action}`, ["bun", "scripts/cli.ts", ...args], `Run HWLAB ${state.spec.lane} web-probe sentinel ${domain} ${action} for node ${state.spec.nodeId}`);
? ["web-probe", "sentinel", "image", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--sentinel", state.sentinelId, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)]
: ["web-probe", "sentinel", "control-plane", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--sentinel", state.sentinelId, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)];
const job = startJob(`hwlab_nodes_${state.spec.lane}_web_probe_sentinel_${safeJobSegment(state.sentinelId)}_${domain}_${action}`, ["bun", "scripts/cli.ts", ...args], `Run HWLAB ${state.spec.lane} web-probe sentinel ${state.sentinelId} ${domain} ${action} for node ${state.spec.nodeId}`);
const command = `web-probe sentinel ${domain} ${action}`;
const result = {
ok: true,
@@ -1415,11 +1467,12 @@ function confirmBlocked(action: string, state: SentinelCicdState): Record<string
function controlPlaneNext(state: SentinelCicdState, action: WebProbeSentinelControlPlaneAction): Record<string, string> {
const node = state.spec.nodeId;
const lane = state.spec.lane;
const suffix = sentinelCliSuffix(state);
return {
plan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${node} --lane ${lane} --dry-run`,
status: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${node} --lane ${lane}`,
image: `bun scripts/cli.ts web-probe sentinel image status --node ${node} --lane ${lane}`,
triggerCurrent: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${node} --lane ${lane} --dry-run`,
plan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${node} --lane ${lane}${suffix} --dry-run`,
status: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${node} --lane ${lane}${suffix}`,
image: `bun scripts/cli.ts web-probe sentinel image status --node ${node} --lane ${lane}${suffix}`,
triggerCurrent: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${node} --lane ${lane}${suffix} --dry-run`,
issue: "https://github.com/pikasTech/unidesk/issues/889",
currentAction: action,
};
@@ -1586,11 +1639,11 @@ function runSentinelReport(state: SentinelCicdState, options: Extract<WebProbeSe
}
function renderAsyncP5Job(state: SentinelCicdState, subcommand: readonly string[], timeoutSeconds: number, releaseId: string | null, reason: string | null, quickVerify: boolean): RenderedCliResult {
const args = ["web-probe", "sentinel", ...subcommand, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)];
const args = ["web-probe", "sentinel", ...subcommand, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--sentinel", state.sentinelId, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)];
if (releaseId !== null) args.push("--release-id", releaseId);
if (reason !== null) args.push("--reason", reason);
if (quickVerify) args.push("--quick-verify");
const job = startJob(`hwlab_nodes_${state.spec.lane}_web_probe_sentinel_${subcommand.join("_")}`, ["bun", "scripts/cli.ts", ...args], `Run HWLAB ${state.spec.lane} web-probe sentinel ${subcommand.join(" ")} for node ${state.spec.nodeId}`);
const job = startJob(`hwlab_nodes_${state.spec.lane}_web_probe_sentinel_${safeJobSegment(state.sentinelId)}_${subcommand.join("_")}`, ["bun", "scripts/cli.ts", ...args], `Run HWLAB ${state.spec.lane} web-probe sentinel ${state.sentinelId} ${subcommand.join(" ")} for node ${state.spec.nodeId}`);
const command = `web-probe sentinel ${subcommand.join(" ")}`;
return rendered(true, command, renderAsyncJobResult({
ok: true,
@@ -1615,7 +1668,11 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
const maxSeconds = numberAt(state.cicd, "targetValidation.maxSeconds");
const scenario = findScenario(state, scenarioId);
if (scenario === null) return { ok: false, status: "blocked", reason: "scenario-not-found", scenarioId, valuesRedacted: true };
const prompts = readPromptSetForScenario(scenario);
const commandSequence = arrayAt(scenario, "commandSequence").map(record);
const needsPromptSet = commandSequence.some((item) => stringAt(item, "type") === "sendPrompt");
const prompts = needsPromptSet
? readPromptSetForScenario(scenario)
: { ok: true as const, prompts: [], summary: { source: "not-required", promptCount: 0, valuesRedacted: true } };
if (!prompts.ok) return { ok: false, status: "blocked", reason: "prompt-source-unavailable", promptSource: prompts, valuesRedacted: true };
const sampleIntervalMs = numberAt(scenario, "sampleIntervalMs");
const budgetSeconds = Math.min(timeoutSeconds, maxSeconds);
@@ -1655,7 +1712,7 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
}
let promptIndex = 0;
const sessionInvarianceChecks = sessionInvarianceChecksByRound(scenario);
for (const item of arrayAt(scenario, "commandSequence").map(record)) {
for (const item of commandSequence) {
const type = stringAt(item, "type");
const repeat = Math.max(1, typeof item.repeat === "number" && Number.isFinite(item.repeat) ? Math.trunc(item.repeat) : 1);
for (let index = 0; index < repeat; index += 1) {
@@ -1675,6 +1732,16 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
}
const args = ["web-probe", "observe", "command", observerId, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--type", type, "--wait-ms", "55000", "--command-timeout-seconds", String(remainingSeconds(deadline, 55))];
if (type === "selectProvider") args.push("--provider", stringAt(item, "provider"));
if (type === "loginAccount" || type === "listSessions" || type === "logout") {
const accountId = stringAtNullable(item, "accountId");
if (accountId !== null) args.push("--account-id", accountId);
}
if (type === "switchSessions") {
const fromAccountId = stringAtNullable(item, "fromAccountId");
const toAccountId = stringAtNullable(item, "toAccountId");
if (fromAccountId !== null) args.push("--from-account-id", fromAccountId);
if (toAccountId !== null) args.push("--to-account-id", toAccountId);
}
if (type === "sendPrompt") {
args.push("--text", prompts.prompts[promptIndex % prompts.prompts.length] ?? "");
promptIndex += 1;
@@ -2152,16 +2219,29 @@ function applySentinelCaddyBlock(state: SentinelCicdState, timeoutSeconds: numbe
const serviceName = stringAt(state.publicExposure, "caddy.serviceName");
const responseHeaderTimeoutSeconds = numberAt(state.publicExposure, "caddy.responseHeaderTimeoutSeconds");
const remotePort = numberAt(state.publicExposure, "frpc.httpProxy.remotePort");
const block = [
`${hostname} {`,
` reverse_proxy 127.0.0.1:${remotePort} {`,
" transport http {",
` response_header_timeout ${responseHeaderTimeoutSeconds}s`,
" }",
const routePrefix = normalizeRoutePrefix(stringAtNullable(state.publicExposure, "routePrefix"));
const proxyLines = [
`reverse_proxy 127.0.0.1:${remotePort} {`,
" transport http {",
` response_header_timeout ${responseHeaderTimeoutSeconds}s`,
" }",
"}",
"",
].join("\n");
];
const block = routePrefix === "/"
? [
`${hostname} {`,
...proxyLines.map((line) => ` ${line}`),
"}",
"",
].join("\n")
: [
`${hostname} {`,
` handle_path ${routePrefix}* {`,
...proxyLines.map((line) => ` ${line}`),
" }",
"}",
"",
].join("\n");
const blockB64 = Buffer.from(block, "utf8").toString("base64");
const script = [
"set +e",
@@ -2229,7 +2309,7 @@ function applySentinelCaddyBlock(state: SentinelCicdState, timeoutSeconds: numbe
].join("\n");
const result = runCommand(["trans", stringAt(state.publicExposure, "caddy.route"), "sh", "--", script], repoRoot, { timeoutMs: Math.min(timeoutSeconds, 60) * 1000 });
const parsed = parseJsonObject(result.stdout);
return { ok: result.exitCode === 0 && parsed?.ok === true, ...record(parsed), result: compactCommand(result), valuesRedacted: true };
return { ok: result.exitCode === 0 && parsed?.ok === true, routePrefix, ...record(parsed), result: compactCommand(result), valuesRedacted: true };
}
function readAnalysisSummaryFromWorkspace(state: SentinelCicdState, stateDir: string, timeoutSeconds: number): Record<string, unknown> {
@@ -2487,11 +2567,9 @@ function cliDataPayload(parsed: Record<string, unknown> | null): Record<string,
}
function findScenario(state: SentinelCicdState, scenarioId: string): Record<string, unknown> | null {
const sentinel = state.spec.observability.webProbe?.sentinel;
if (sentinel === undefined) return null;
const scenarios = readConfigRefTarget(sentinel.configRefs.scenarios);
if (!Array.isArray(scenarios)) return null;
return scenarios.map(record).find((item) => item.id === scenarioId) ?? null;
const scenarios = readConfigRefTarget(state.configRefs.scenarios);
const items = Array.isArray(scenarios) ? scenarios : isRecord(scenarios) ? [scenarios] : [];
return items.map(record).find((item) => item.id === scenarioId) ?? null;
}
function readPromptSetForScenario(scenario: Record<string, unknown>): { ok: true; prompts: string[]; summary: Record<string, unknown> } | { ok: false; error: string; summary: Record<string, unknown> } {
@@ -2595,7 +2673,7 @@ function serviceUnavailableBlocker(state: SentinelCicdState): Record<string, unk
code: "sentinel-service-unavailable",
policy: stringAt(state.cicd, "targetValidation.serviceUnavailablePolicy"),
reason: "sentinel service must be reachable through k3s internal Service DNS before quick verify can run; no public/fallback path is used.",
retry: `bun scripts/cli.ts web-probe sentinel validate --node ${state.spec.nodeId} --lane ${state.spec.lane}`,
retry: `bun scripts/cli.ts web-probe sentinel validate --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
valuesRedacted: true,
};
}
@@ -2603,12 +2681,13 @@ function serviceUnavailableBlocker(state: SentinelCicdState): Record<string, unk
function sentinelP5Next(state: SentinelCicdState): Record<string, string> {
const node = state.spec.nodeId;
const lane = state.spec.lane;
const suffix = sentinelCliSuffix(state);
return {
validate: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane}`,
quickVerify: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane} --quick-verify --confirm --wait`,
maintenanceStart: `bun scripts/cli.ts web-probe sentinel maintenance start --node ${node} --lane ${lane} --confirm --wait`,
maintenanceStop: `bun scripts/cli.ts web-probe sentinel maintenance stop --node ${node} --lane ${lane} --confirm --wait`,
report: `bun scripts/cli.ts web-probe sentinel report --node ${node} --lane ${lane} --view summary`,
validate: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane}${suffix}`,
quickVerify: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane}${suffix} --quick-verify --confirm --wait`,
maintenanceStart: `bun scripts/cli.ts web-probe sentinel maintenance start --node ${node} --lane ${lane}${suffix} --confirm --wait`,
maintenanceStop: `bun scripts/cli.ts web-probe sentinel maintenance stop --node ${node} --lane ${lane}${suffix} --confirm --wait`,
report: `bun scripts/cli.ts web-probe sentinel report --node ${node} --lane ${lane}${suffix} --view summary`,
};
}
@@ -2805,7 +2884,20 @@ function renderReportResult(result: Record<string, unknown>): string {
function sentinelPipelineRunName(state: SentinelCicdState): string {
const commit = state.sourceHead.commit ?? "source";
return `hwlab-web-probe-sentinel-${commit.slice(0, 12)}`;
return `hwlab-web-probe-sentinel-${safeKubernetesSegment(state.sentinelId, 24)}-${commit.slice(0, 12)}`;
}
function sentinelCliSuffix(state: SentinelCicdState): string {
return ` --sentinel ${state.sentinelId}`;
}
function safeJobSegment(value: string): string {
return value.replace(/[^A-Za-z0-9_]+/gu, "_").replace(/^_+|_+$/gu, "").slice(0, 48) || "sentinel";
}
function safeKubernetesSegment(value: string, maxLength: number): string {
const normalized = value.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/^-+|-+$/gu, "");
return (normalized || "sentinel").slice(0, Math.max(1, maxLength)).replace(/-+$/u, "") || "sentinel";
}
function renderImageResult(result: Record<string, unknown>): string {