210 lines
8.6 KiB
TypeScript
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);
|
|
}
|