Files
pikasTech-unidesk/scripts/src/hwlab-node-web-sentinel-config-ref.ts
T
2026-06-29 08:13:34 +00:00

210 lines
8.6 KiB
TypeScript

// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-29-p14-yaml-template-refs.
// Responsibility: Render YAML-first web-probe sentinel configRefs with node/lane variables and selector paths.
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { rootPath } from "./config";
export interface WebProbeSentinelTemplateContext {
readonly nodeId: string;
readonly lane: string;
readonly runtimeNamespace?: string;
}
type TemplateVarValue = string | number | boolean;
export interface WebProbeSentinelRenderedConfigRef {
readonly originalRef: string;
readonly ref: string;
readonly file: string;
readonly path: string;
readonly target: unknown;
readonly sha256: string;
readonly byteCount: number;
readonly variableNames: readonly string[];
}
export function readWebProbeSentinelConfigRefTarget(context: WebProbeSentinelTemplateContext, ref: string): unknown {
return readWebProbeSentinelConfigRef(context, ref).target;
}
export function readWebProbeSentinelConfigRef(context: WebProbeSentinelTemplateContext, ref: string): WebProbeSentinelRenderedConfigRef {
const builtinVars = templateContextVars(context);
const renderedRef = renderTemplateString(ref, builtinVars, "configRef");
const parsed = parseRenderedConfigRef(renderedRef);
const absPath = rootPath(parsed.file);
if (!existsSync(absPath)) throw new Error(`${parsed.file} does not exist`);
const text = readFileSync(absPath, "utf8");
const doc = Bun.YAML.parse(text) as unknown;
const vars = { ...builtinVars, ...documentVars(doc, builtinVars) };
const path = renderTemplateString(parsed.path, vars, `${parsed.file}#path`);
const target = valueAtConfigPath(doc, path);
if (target === undefined) throw new Error(`${parsed.file}#${path} is missing`);
return {
originalRef: ref,
ref: `${parsed.file}#${path}`,
file: parsed.file,
path,
target: renderTemplateValue(target, vars),
sha256: `sha256:${createHash("sha256").update(text).digest("hex")}`,
byteCount: Buffer.byteLength(text),
variableNames: Object.keys(vars).sort(),
};
}
export function renderWebProbeSentinelConfigRefString(context: WebProbeSentinelTemplateContext, ref: string): string {
return renderTemplateString(ref, templateContextVars(context), "configRef");
}
function templateContextVars(context: WebProbeSentinelTemplateContext): Record<string, TemplateVarValue> {
const node = context.nodeId;
const nodeLower = node.toLowerCase();
const lane = context.lane;
const laneMinor = lane.replace(/^v/u, "");
return {
NODE: node,
node,
nodeLower,
NODE_LOWER: nodeLower,
LANE: lane,
lane,
laneMinor,
LANE_MINOR: laneMinor,
nodeLane: `${nodeLower}-${lane}`,
NODE_LANE: `${node}-${lane}`,
runtimeNamespace: context.runtimeNamespace ?? "",
...contextTemplateVars(context),
};
}
function contextTemplateVars(context: WebProbeSentinelTemplateContext): Record<string, TemplateVarValue> {
const observability = isRecord((context as { readonly observability?: unknown }).observability)
? (context as { readonly observability: Record<string, unknown> }).observability
: {};
const webProbe = isRecord(observability.webProbe) ? observability.webProbe : {};
const templateVars = isRecord(webProbe.templateVars) ? webProbe.templateVars : {};
const result: Record<string, TemplateVarValue> = {};
for (const [key, value] of Object.entries(templateVars)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`observability.webProbe.templateVars.${key} is not a supported variable name`);
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
throw new Error(`observability.webProbe.templateVars.${key} must be a scalar`);
}
result[key] = value;
}
return result;
}
function documentVars(doc: unknown, vars: Record<string, TemplateVarValue>): Record<string, TemplateVarValue> {
if (!isRecord(doc) || !isRecord(doc.vars)) return {};
const result: Record<string, TemplateVarValue> = {};
for (const [key, value] of Object.entries(doc.vars)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`vars.${key} is not a supported variable name`);
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
throw new Error(`vars.${key} must be a scalar`);
}
result[key] = typeof value === "string"
? renderTemplateString(value, { ...vars, ...result }, `vars.${key}`)
: value;
}
return result;
}
function renderTemplateValue(value: unknown, vars: Record<string, TemplateVarValue>): unknown {
if (typeof value === "string") return renderTemplateScalar(value, vars, "YAML string");
if (Array.isArray(value)) return value.map((item) => renderTemplateValue(item, vars));
if (!isRecord(value)) return value;
const localVars = localTemplateVars(value, vars);
const rendered: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
if (key === "vars") continue;
rendered[key] = renderTemplateValue(item, localVars);
}
return rendered;
}
function localTemplateVars(value: Record<string, unknown>, parentVars: Record<string, TemplateVarValue>): Record<string, TemplateVarValue> {
if (!isRecord(value.vars)) return parentVars;
const local: Record<string, TemplateVarValue> = {};
for (const [key, item] of Object.entries(value.vars)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`vars.${key} is not a supported variable name`);
if (typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean") throw new Error(`vars.${key} must be a scalar`);
local[key] = typeof item === "string"
? renderTemplateString(item, { ...parentVars, ...local }, `vars.${key}`)
: item;
}
return { ...parentVars, ...local };
}
function renderTemplateScalar(value: string, vars: Record<string, TemplateVarValue>, label: string): TemplateVarValue {
const whole = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/u.exec(value);
if (whole !== null) {
const rendered = vars[whole[1]];
if (rendered === undefined) throw new Error(`${label} references undefined variable ${whole[1]}`);
return rendered;
}
return renderTemplateString(value, vars, label);
}
function renderTemplateString(value: string, vars: Record<string, TemplateVarValue>, label: string): string {
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/gu, (_match, key: string) => {
const rendered = vars[key];
if (rendered === undefined) throw new Error(`${label} references undefined variable ${key}`);
return String(rendered);
});
}
function parseRenderedConfigRef(ref: string): { readonly file: string; readonly path: string } {
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 ..`);
}
return { file, path };
}
function valueAtConfigPath(value: unknown, path: string): unknown {
let current = value;
for (const segment of path.split(".")) {
if (segment.length === 0) return undefined;
current = valueAtSegment(current, segment);
if (current === undefined) return undefined;
}
return current;
}
function valueAtSegment(value: unknown, segment: string): unknown {
let rest = segment;
let current = value;
const key = /^[A-Za-z0-9_-]+/u.exec(rest)?.[0] ?? null;
if (key !== null) {
if (!isRecord(current)) return undefined;
current = current[key];
rest = rest.slice(key.length);
}
if (key === null && !rest.startsWith("[")) return undefined;
while (rest.length > 0) {
const match = /^\[([^\]]+)\]/u.exec(rest);
if (match === null) return undefined;
current = valueAtBracket(current, match[1]);
if (current === undefined) return undefined;
rest = rest.slice(match[0].length);
}
return current;
}
function valueAtBracket(value: unknown, selector: string): unknown {
if (/^\d+$/u.test(selector)) {
if (!Array.isArray(value)) return undefined;
return value[Number(selector)];
}
const match = /^([A-Za-z0-9_-]+)=([A-Za-z0-9_.:/@+-]+)$/u.exec(selector);
if (match === null || !Array.isArray(value)) return undefined;
const [, key, expected] = match;
return value.find((item) => isRecord(item) && item[key] === expected);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}