- 新增 config-contract 模块(TypeBox fragments、Ajv 契约校验、ConfigValidationIssue) - CheckerDefinition 扩展为含 configKey、schemas、validate 的完整插件接口 - HTTP/Command 各自维护 contract.ts + validate.ts,校验从 resolve 中分离 - resolve 不再承担校验,只做默认值合并和路径/单位解析 - config-loader 流程: unknown → RawProbeConfig → ValidatedProbeConfig → ResolvedConfig - 导出 probe-config.schema.json,新增 schema/schema:check 脚本 - 更新 DEVELOPMENT.md 新增 1.7 开发新 Checker 完整指引 - 同步更新 4 个 main specs(probe-config、command-checker、expect-body-checkers、checker-runner-abstraction)
246 lines
7.9 KiB
TypeScript
246 lines
7.9 KiB
TypeScript
import { dirname, resolve } from "node:path";
|
|
|
|
import type { ConfigValidationIssue } from "./config-contract/issues";
|
|
import type { DefaultsConfig, EngineRuntimeConfig, ResolvedTarget, TargetConfig } from "./types";
|
|
|
|
import { issue, throwConfigIssues } from "./config-contract/issues";
|
|
import { asValidatedConfig, type RawProbeConfig } from "./config-contract/types";
|
|
import { validateProbeConfigContract } from "./config-contract/validate";
|
|
import { checkerRegistry } from "./runner";
|
|
|
|
const DEFAULT_HOST = "127.0.0.1";
|
|
const DEFAULT_PORT = 3000;
|
|
const DEFAULT_DATA_DIR = "./data";
|
|
const DEFAULT_INTERVAL = "30s";
|
|
const DEFAULT_TIMEOUT = "10s";
|
|
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
|
|
|
|
export interface ResolvedConfig {
|
|
configDir: string;
|
|
dataDir: string;
|
|
host: string;
|
|
maxConcurrentChecks: number;
|
|
port: number;
|
|
targets: ResolvedTarget[];
|
|
}
|
|
|
|
export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
|
const file = Bun.file(configPath);
|
|
|
|
if (!(await file.exists())) {
|
|
throw new Error(`配置文件不存在: ${configPath}`);
|
|
}
|
|
|
|
const content = await file.text();
|
|
const parsed = Bun.YAML.parse(content);
|
|
|
|
if (!parsed) {
|
|
throw new Error("配置文件内容为空或格式无效");
|
|
}
|
|
|
|
const contractResult = validateProbeConfigContract(parsed, checkerRegistry);
|
|
if (contractResult.config === null && !canRunSemanticValidation(parsed)) {
|
|
throwConfigIssues(contractResult.issues);
|
|
}
|
|
const semanticInput = (contractResult.config ?? parsed) as RawProbeConfig;
|
|
const validationIssues = validateConfig(semanticInput);
|
|
|
|
const allIssues = [...contractResult.issues, ...validationIssues];
|
|
if (contractResult.config === null) {
|
|
if (allIssues.length > 0) {
|
|
throwConfigIssues(dedupeIssues(allIssues));
|
|
}
|
|
throw new Error("配置文件内容为空或格式无效");
|
|
}
|
|
|
|
const raw = contractResult.config;
|
|
|
|
const validated = asValidatedConfig(raw);
|
|
|
|
const configDir = dirname(resolve(configPath));
|
|
const server = validated.server ?? {};
|
|
const runtime = validated.runtime ?? {};
|
|
const defaults = validated.defaults ?? {};
|
|
|
|
const host = server.host ?? DEFAULT_HOST;
|
|
const port = server.port ?? DEFAULT_PORT;
|
|
const dataDir = server.dataDir ?? DEFAULT_DATA_DIR;
|
|
|
|
const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime);
|
|
|
|
const allRuntimeIssues = [...allIssues];
|
|
if (allRuntimeIssues.length > 0) {
|
|
throwConfigIssues(dedupeIssues(allRuntimeIssues));
|
|
}
|
|
|
|
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
|
|
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT);
|
|
|
|
const targets: ResolvedTarget[] = validated.targets.map((target) =>
|
|
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
|
|
);
|
|
|
|
return { configDir, dataDir, host, maxConcurrentChecks, port, targets };
|
|
}
|
|
|
|
function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
|
|
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
|
|
if (
|
|
typeof runtime.maxConcurrentChecks !== "number" ||
|
|
!Number.isInteger(runtime.maxConcurrentChecks) ||
|
|
runtime.maxConcurrentChecks <= 0
|
|
)
|
|
return DEFAULT_MAX_CONCURRENT_CHECKS;
|
|
return runtime.maxConcurrentChecks;
|
|
}
|
|
|
|
function resolveTarget(
|
|
target: TargetConfig,
|
|
defaults: DefaultsConfig,
|
|
defaultIntervalMs: number,
|
|
defaultTimeoutMs: number,
|
|
configDir: string,
|
|
): ResolvedTarget {
|
|
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
|
|
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
|
|
|
|
const checker = checkerRegistry.get(target.type);
|
|
const result = checker.resolve(target, { configDir, defaultIntervalMs, defaults, defaultTimeoutMs });
|
|
|
|
result.intervalMs = intervalMs;
|
|
result.timeoutMs = timeoutMs;
|
|
|
|
return result;
|
|
}
|
|
|
|
function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
|
|
const issues: ConfigValidationIssue[] = [];
|
|
if (!Array.isArray(config.targets) || config.targets.length === 0) {
|
|
issues.push(issue("required", "targets", "配置文件必须包含至少一个 target"));
|
|
return issues;
|
|
}
|
|
const names = 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)) {
|
|
issues.push(issue("invalid-type", `targets[${i}]`, "必须为对象"));
|
|
continue;
|
|
}
|
|
const raw = rawTarget;
|
|
|
|
const name = raw["name"];
|
|
if (!name || typeof name !== "string" || name.trim() === "") {
|
|
issues.push(issue("required", `targets[${i}].name`, "缺少 name 字段"));
|
|
continue;
|
|
}
|
|
|
|
const type = raw["type"];
|
|
if (!type || typeof type !== "string") {
|
|
issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name));
|
|
continue;
|
|
}
|
|
|
|
if (!supportedTypes.includes(type)) {
|
|
issues.push(
|
|
issue(
|
|
"unsupported-type",
|
|
`targets[${i}].type`,
|
|
`使用不支持的 type: "${type}",支持: ${supportedTypes.join(", ")}`,
|
|
name,
|
|
),
|
|
);
|
|
}
|
|
|
|
const group = raw["group"];
|
|
if (group !== undefined && typeof group !== "string") {
|
|
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));
|
|
}
|
|
|
|
names.add(name);
|
|
}
|
|
|
|
for (const checker of checkerRegistry.definitions) {
|
|
issues.push(...checker.validate({ defaults: config.defaults ?? {}, targets: config.targets }));
|
|
}
|
|
|
|
validateDurationValue(config.defaults?.interval, "defaults.interval", issues);
|
|
validateDurationValue(config.defaults?.timeout, "defaults.timeout", 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;
|
|
validateDurationValue(
|
|
typeof target["interval"] === "string" ? target["interval"] : undefined,
|
|
`targets[${i}].interval`,
|
|
issues,
|
|
targetName,
|
|
);
|
|
validateDurationValue(
|
|
typeof target["timeout"] === "string" ? target["timeout"] : undefined,
|
|
`targets[${i}].timeout`,
|
|
issues,
|
|
targetName,
|
|
);
|
|
}
|
|
|
|
return issues;
|
|
}
|
|
|
|
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
|
|
|
|
export function parseDuration(value: string): number {
|
|
const match = DURATION_REGEX.exec(value);
|
|
if (!match) {
|
|
throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`);
|
|
}
|
|
|
|
const num = parseFloat(match[1]!);
|
|
const unit = match[2]!;
|
|
|
|
const durationMs = unit === "ms" ? num : unit === "s" ? num * 1000 : num * 60 * 1000;
|
|
if (!Number.isInteger(durationMs) || durationMs <= 0 || !Number.isFinite(durationMs)) {
|
|
throw new Error(`无效的时长格式: "${value}",解析结果必须为正整数毫秒`);
|
|
}
|
|
return durationMs;
|
|
}
|
|
|
|
function canRunSemanticValidation(value: unknown): boolean {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
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 ?? ""}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
result.push(item);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function validateDurationValue(
|
|
value: string | undefined,
|
|
path: string,
|
|
issues: ConfigValidationIssue[],
|
|
targetName?: string,
|
|
): void {
|
|
if (value === undefined) return;
|
|
try {
|
|
parseDuration(value);
|
|
} catch (error) {
|
|
issues.push(issue("invalid-duration", path, error instanceof Error ? error.message : "时长格式不合法", targetName));
|
|
}
|
|
}
|