feat: 配置变量系统与 target id/name 双字段标识
- 新增顶层 variables 段支持 string/number/boolean 字面量
- target 字符串字段支持 、、{...} 转义语法
- 变量解析优先级: variables -> process.env -> 默认值 -> 报错
- 完整引用保留原始类型,部分引用拼接为字符串
- 变量替换在 YAML 解析后、AJV 校验前执行
- 替换仅作用于 targets,跳过 id/type 字段
- target 新增必填 id 字段作为唯一标识,name 改为可选展示名称
- 数据库存储/API/前端全面迁移到 id 标识
- 统一 checker 运行时类型检查为 es-toolkit predicates
- 同步 delta specs 到主 specs,归档 config-variables 变更
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { isNumber, isPlainObject, isString } from "es-toolkit";
|
||||
import { isArray } from "es-toolkit/compat";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import type { ConfigValidationIssue } from "./schema/issues";
|
||||
@@ -8,6 +10,7 @@ import { issue, throwConfigIssues } from "./schema/issues";
|
||||
import { asValidatedConfig, type RawProbeConfig } from "./schema/types";
|
||||
import { validateProbeConfigContract } from "./schema/validate";
|
||||
import { parseDuration } from "./utils";
|
||||
import { resolveVariables } from "./variables";
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 3000;
|
||||
@@ -41,11 +44,17 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
throw new Error("配置文件内容为空或格式无效");
|
||||
}
|
||||
|
||||
const contractResult = validateProbeConfigContract(parsed, checkerRegistry);
|
||||
if (contractResult.config === null && !canRunSemanticValidation(parsed)) {
|
||||
const variableResult = resolveVariables(parsed);
|
||||
if (variableResult.issues.length > 0) {
|
||||
throwConfigIssues(dedupeIssues(variableResult.issues));
|
||||
}
|
||||
|
||||
const resolvedVariablesConfig = variableResult.config;
|
||||
const contractResult = validateProbeConfigContract(resolvedVariablesConfig, checkerRegistry);
|
||||
if (contractResult.config === null && !canRunSemanticValidation(resolvedVariablesConfig)) {
|
||||
throwConfigIssues(contractResult.issues);
|
||||
}
|
||||
const semanticInput = (contractResult.config ?? parsed) as RawProbeConfig;
|
||||
const semanticInput = (contractResult.config ?? resolvedVariablesConfig) as RawProbeConfig;
|
||||
const validationIssues = validateConfig(semanticInput);
|
||||
|
||||
const allIssues = [...contractResult.issues, ...validationIssues];
|
||||
@@ -88,14 +97,14 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
}
|
||||
|
||||
function canRunSemanticValidation(value: unknown): boolean {
|
||||
return typeof value === "object" && value !== null;
|
||||
return isPlainObject(value);
|
||||
}
|
||||
|
||||
function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ConfigValidationIssue[] = [];
|
||||
for (const item of issues) {
|
||||
const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}`;
|
||||
const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}:${item.targetId ?? ""}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
@@ -103,16 +112,12 @@ function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[]
|
||||
return result;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export { parseDuration } from "./utils";
|
||||
|
||||
function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
|
||||
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
|
||||
if (
|
||||
typeof runtime.maxConcurrentChecks !== "number" ||
|
||||
!isNumber(runtime.maxConcurrentChecks) ||
|
||||
!Number.isInteger(runtime.maxConcurrentChecks) ||
|
||||
runtime.maxConcurrentChecks <= 0
|
||||
)
|
||||
@@ -145,29 +150,36 @@ function resolveTarget(
|
||||
|
||||
function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
if (!Array.isArray(config.targets) || config.targets.length === 0) {
|
||||
if (!isArray(config.targets) || config.targets.length === 0) {
|
||||
issues.push(issue("required", "targets", "配置文件必须包含至少一个 target"));
|
||||
return issues;
|
||||
}
|
||||
const names = new Set<string>();
|
||||
const ids = new Set<string>();
|
||||
const supportedTypes = checkerRegistry.supportedTypes;
|
||||
|
||||
for (let i = 0; i < config.targets.length; i++) {
|
||||
const rawTarget = config.targets[i] as unknown;
|
||||
if (!isRecord(rawTarget)) {
|
||||
if (!isPlainObject(rawTarget)) {
|
||||
issues.push(issue("invalid-type", `targets[${i}]`, "必须为对象"));
|
||||
continue;
|
||||
}
|
||||
const raw = rawTarget;
|
||||
const raw = rawTarget as Record<string, unknown>;
|
||||
|
||||
const name = raw["name"];
|
||||
if (!name || typeof name !== "string" || name.trim() === "") {
|
||||
issues.push(issue("required", `targets[${i}].name`, "缺少 name 字段"));
|
||||
const id: unknown = raw["id"];
|
||||
if (!isString(id) || id.trim() === "") {
|
||||
issues.push(issue("required", `targets[${i}].id`, "缺少 id 字段"));
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = raw["type"];
|
||||
if (!type || typeof type !== "string") {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(id)) {
|
||||
issues.push(issue("invalid-format", `targets[${i}].id`, "id 不符合命名规则", id));
|
||||
}
|
||||
|
||||
const nameValue: unknown = raw["name"];
|
||||
const name = isString(nameValue) ? nameValue : id;
|
||||
|
||||
const type: unknown = raw["type"];
|
||||
if (!isString(type)) {
|
||||
issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name));
|
||||
continue;
|
||||
}
|
||||
@@ -183,16 +195,16 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
|
||||
);
|
||||
}
|
||||
|
||||
const group = raw["group"];
|
||||
if (group !== undefined && typeof group !== "string") {
|
||||
const group: unknown = raw["group"];
|
||||
if (group !== undefined && !isString(group)) {
|
||||
issues.push(issue("invalid-type", `targets[${i}].group`, "必须为字符串", name));
|
||||
}
|
||||
|
||||
if (names.has(name)) {
|
||||
issues.push(issue("duplicate-name", `targets[${i}].name`, `target name 重复: "${name}"`, name));
|
||||
if (ids.has(id)) {
|
||||
issues.push(issue("duplicate-id", `targets[${i}].id`, `target id 重复: "${id}"`, name));
|
||||
}
|
||||
|
||||
names.add(name);
|
||||
ids.add(id);
|
||||
}
|
||||
|
||||
for (const checker of checkerRegistry.definitions) {
|
||||
@@ -202,22 +214,29 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
|
||||
validateDurationValue(config.defaults?.interval, "defaults.interval", issues);
|
||||
validateDurationValue(config.defaults?.timeout, "defaults.timeout", issues);
|
||||
validateDurationValue(
|
||||
typeof config.runtime?.retention === "string" ? config.runtime.retention : undefined,
|
||||
isString(config.runtime?.retention) ? config.runtime.retention : undefined,
|
||||
"runtime.retention",
|
||||
issues,
|
||||
);
|
||||
for (let i = 0; i < config.targets.length; i++) {
|
||||
const target = config.targets[i] as unknown;
|
||||
if (!isRecord(target)) continue;
|
||||
const targetName = typeof target["name"] === "string" ? target["name"] : undefined;
|
||||
if (!isPlainObject(target)) continue;
|
||||
const targetRecord = target as Record<string, unknown>;
|
||||
const targetNameValue: unknown = targetRecord["name"];
|
||||
const targetIdValue: unknown = targetRecord["id"];
|
||||
const targetName = isString(targetNameValue)
|
||||
? targetNameValue
|
||||
: isString(targetIdValue)
|
||||
? targetIdValue
|
||||
: undefined;
|
||||
validateDurationValue(
|
||||
typeof target["interval"] === "string" ? target["interval"] : undefined,
|
||||
isString(targetRecord["interval"]) ? targetRecord["interval"] : undefined,
|
||||
`targets[${i}].interval`,
|
||||
issues,
|
||||
targetName,
|
||||
);
|
||||
validateDurationValue(
|
||||
typeof target["timeout"] === "string" ? target["timeout"] : undefined,
|
||||
isString(targetRecord["timeout"]) ? targetRecord["timeout"] : undefined,
|
||||
`targets[${i}].timeout`,
|
||||
issues,
|
||||
targetName,
|
||||
|
||||
Reference in New Issue
Block a user