208 lines
9.0 KiB
TypeScript
208 lines
9.0 KiB
TypeScript
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
|
|
// Responsibility: Resolve YAML-first web-probe sentinel registry entries into one selected sentinel config graph.
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { rootPath } from "./config";
|
|
import { readWebProbeSentinelConfigRefTarget, type WebProbeSentinelTemplateContext } from "./hwlab-node-web-sentinel-config-ref";
|
|
import { HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS, type HwlabRuntimeLaneSpec, type HwlabRuntimeWebProbeSentinelConfigRefKey, type HwlabRuntimeWebProbeSentinelRegistryItemSpec } from "./hwlab-node-lanes";
|
|
|
|
export interface ResolvedWebProbeSentinel {
|
|
readonly id: string;
|
|
readonly enabled: boolean;
|
|
readonly mode: "registry" | "legacy";
|
|
readonly rootPath: string;
|
|
readonly configRef: string | null;
|
|
readonly configRefs: Record<HwlabRuntimeWebProbeSentinelConfigRefKey, string>;
|
|
readonly target: Record<string, unknown>;
|
|
}
|
|
|
|
export interface WebProbeSentinelRegistryRow {
|
|
readonly id: string;
|
|
readonly enabled: boolean;
|
|
readonly configRef: string;
|
|
readonly monitorRoot: boolean;
|
|
readonly publicBaseUrl?: string;
|
|
readonly routePrefix?: string;
|
|
}
|
|
|
|
export function webProbeSentinelRegistryRows(spec: HwlabRuntimeLaneSpec): readonly WebProbeSentinelRegistryRow[] {
|
|
const registry = spec.observability.webProbe?.sentinels;
|
|
if (registry !== undefined) return registry.map((item) => sentinelRegistryRow(spec, item.id, item.enabled, item.configRef));
|
|
const legacy = spec.observability.webProbe?.sentinel;
|
|
if (legacy === undefined) return [];
|
|
return [sentinelRegistryRow(
|
|
spec,
|
|
"workbench-dsflash-go-tool-call-10x",
|
|
legacy.enabled,
|
|
`config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel`,
|
|
)];
|
|
}
|
|
|
|
export function effectiveWebProbeSentinelPublicExposure(spec: HwlabRuntimeLaneSpec, sentinelId: string, publicExposure: Record<string, unknown>): Record<string, unknown> {
|
|
const monitorRoot = spec.observability.webProbe?.monitorRoot;
|
|
if (monitorRoot === undefined || monitorRoot.sentinelId !== sentinelId) return publicExposure;
|
|
if (!monitorRoot.enabled) {
|
|
return {
|
|
...publicExposure,
|
|
monitorRoot: {
|
|
enabled: false,
|
|
sentinelId,
|
|
caddyManagedBlockOwner: monitorRoot.caddyManagedBlockOwner,
|
|
valuesRedacted: true,
|
|
},
|
|
};
|
|
}
|
|
const caddy = isRecord(publicExposure.caddy) ? publicExposure.caddy : {};
|
|
return {
|
|
...publicExposure,
|
|
publicBaseUrl: monitorRoot.publicBaseUrl,
|
|
routePrefix: monitorRoot.routePrefix,
|
|
caddy: {
|
|
...caddy,
|
|
managedBlockOwner: monitorRoot.caddyManagedBlockOwner,
|
|
rootOrder: "active",
|
|
},
|
|
monitorRoot: {
|
|
enabled: true,
|
|
sentinelId,
|
|
publicBaseUrl: monitorRoot.publicBaseUrl,
|
|
routePrefix: monitorRoot.routePrefix,
|
|
caddyManagedBlockOwner: monitorRoot.caddyManagedBlockOwner,
|
|
valuesRedacted: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function resolveWebProbeSentinel(spec: HwlabRuntimeLaneSpec, sentinelId: string | null | undefined): ResolvedWebProbeSentinel {
|
|
const registry = spec.observability.webProbe?.sentinels;
|
|
if (registry !== undefined) return resolveRegistrySentinel(spec, registry, sentinelId ?? null);
|
|
const legacy = spec.observability.webProbe?.sentinel;
|
|
if (legacy === undefined) {
|
|
throw new Error(`config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinels is missing`);
|
|
}
|
|
const id = sentinelId ?? "workbench-dsflash-go-tool-call-10x";
|
|
return {
|
|
id,
|
|
enabled: legacy.enabled,
|
|
mode: "legacy",
|
|
rootPath: `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel`,
|
|
configRef: null,
|
|
configRefs: legacy.configRefs,
|
|
target: {
|
|
id,
|
|
configRefs: legacy.configRefs,
|
|
valuesRedacted: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function requireSentinelIdForRegistry(spec: HwlabRuntimeLaneSpec, sentinelId: string | null | undefined, command: string): void {
|
|
const registry = spec.observability.webProbe?.sentinels;
|
|
if (registry !== undefined && registry.length > 1 && (sentinelId === null || sentinelId === undefined || sentinelId.length === 0)) {
|
|
const ids = registry.map((item) => item.id).join(", ");
|
|
throw new Error(`${command} requires --sentinel <id> when multiple sentinels are declared; available: ${ids}`);
|
|
}
|
|
}
|
|
|
|
function resolveRegistrySentinel(spec: HwlabRuntimeLaneSpec, registry: readonly HwlabRuntimeWebProbeSentinelRegistryItemSpec[], sentinelId: string | null): ResolvedWebProbeSentinel {
|
|
const selected = sentinelId === null && registry.length === 1
|
|
? registry[0]
|
|
: registry.find((item) => item.id === sentinelId);
|
|
if (selected === undefined) {
|
|
const ids = registry.map((item) => item.id).join(", ");
|
|
throw new Error(`unknown web-probe sentinel ${sentinelId ?? "-"} for ${spec.nodeId}/${spec.lane}; available: ${ids}`);
|
|
}
|
|
const target = readConfigRefRecord(selected.configRef, spec);
|
|
const targetId = optionalStringAt(target, "id") ?? selected.id;
|
|
if (targetId !== selected.id) {
|
|
throw new Error(`${selected.configRef}.id=${targetId} does not match registry id ${selected.id}`);
|
|
}
|
|
return {
|
|
id: selected.id,
|
|
enabled: selected.enabled,
|
|
mode: "registry",
|
|
rootPath: `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinels.${selected.id}`,
|
|
configRef: selected.configRef,
|
|
configRefs: normalizeSentinelConfigRefs(target, selected.configRef),
|
|
target,
|
|
};
|
|
}
|
|
|
|
function sentinelRegistryRow(spec: HwlabRuntimeLaneSpec, id: string, enabled: boolean, configRef: string): WebProbeSentinelRegistryRow {
|
|
const monitorRoot = spec.observability.webProbe?.monitorRoot;
|
|
const isMonitorRoot = monitorRoot?.enabled === true && monitorRoot.sentinelId === id;
|
|
return {
|
|
id,
|
|
enabled,
|
|
configRef,
|
|
monitorRoot: isMonitorRoot,
|
|
...(isMonitorRoot ? { publicBaseUrl: monitorRoot.publicBaseUrl, routePrefix: monitorRoot.routePrefix } : {}),
|
|
};
|
|
}
|
|
|
|
function normalizeSentinelConfigRefs(target: Record<string, unknown>, ref: string): Record<HwlabRuntimeWebProbeSentinelConfigRefKey, string> {
|
|
const rawRefs = recordAt(target, "configRefs");
|
|
const normalized: Record<string, string> = {};
|
|
for (const key of Object.keys(rawRefs)) {
|
|
const value = rawRefs[key];
|
|
if (typeof value === "string" && value.length > 0) normalized[key] = value;
|
|
}
|
|
if (normalized.scenarios === undefined && normalized.workflow !== undefined) normalized.scenarios = normalized.workflow;
|
|
const missing = HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.filter((key) => normalized[key] === undefined);
|
|
if (missing.length > 0) throw new Error(`${ref}.configRefs is missing ${missing.join(",")}`);
|
|
return Object.fromEntries(HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.map((key) => [key, normalized[key]])) as Record<HwlabRuntimeWebProbeSentinelConfigRefKey, string>;
|
|
}
|
|
|
|
function readConfigRefRecord(ref: string, context: WebProbeSentinelTemplateContext): Record<string, unknown> {
|
|
const target = readConfigRefTarget(ref, context);
|
|
if (!isRecord(target)) throw new Error(`${ref} must point to a YAML object`);
|
|
return target;
|
|
}
|
|
|
|
export function readConfigRefTarget(ref: string, context?: WebProbeSentinelTemplateContext): unknown {
|
|
if (context !== undefined) return readWebProbeSentinelConfigRefTarget(context, ref);
|
|
const [file, path, extra] = ref.split("#");
|
|
if (extra !== undefined || file === undefined || path === undefined || file.length === 0 || path.length === 0) {
|
|
throw new Error(`${ref} must use path/to/file.yaml#object.path syntax`);
|
|
}
|
|
if (file.startsWith("/") || file.includes("\0") || file.includes("..") || !file.startsWith("config/") || !file.endsWith(".yaml")) {
|
|
throw new Error(`${ref} must reference a repo-relative config/*.yaml file without ..`);
|
|
}
|
|
const absPath = rootPath(file);
|
|
if (!existsSync(absPath)) throw new Error(`${file} does not exist`);
|
|
const doc = Bun.YAML.parse(readFileSync(absPath, "utf8")) as unknown;
|
|
return valueAtPath(doc, path);
|
|
}
|
|
|
|
function recordAt(value: unknown, path: string): Record<string, unknown> {
|
|
const found = valueAtPath(value, path);
|
|
if (!isRecord(found)) throw new Error(`${path} must be an object`);
|
|
return found;
|
|
}
|
|
|
|
function optionalStringAt(value: unknown, path: string): string | null {
|
|
const found = valueAtPath(value, path);
|
|
return typeof found === "string" && found.length > 0 ? found : null;
|
|
}
|
|
|
|
function valueAtPath(value: unknown, path: string): unknown {
|
|
let current: unknown = value;
|
|
for (const segment of path.split(".")) {
|
|
if (segment.length === 0) return undefined;
|
|
const match = /^(?:([A-Za-z0-9_-]+))?(?:\[(\d+)\])?$/u.exec(segment);
|
|
if (match === null) return undefined;
|
|
if (match[1] !== undefined) {
|
|
if (!isRecord(current)) return undefined;
|
|
current = current[match[1]];
|
|
}
|
|
if (match[2] !== undefined) {
|
|
if (!Array.isArray(current)) return undefined;
|
|
current = current[Number(match[2])];
|
|
}
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|