- 新增 server.listen (host/port)、server.storage (dataDir/retention)、 server.logging 分组 - 新增 probes.execution (maxConcurrentChecks) 分组,替代顶层 runtime - 旧配置入口 (runtime/logging/server.host/server.port/server.dataDir) 启动期拒绝 - 更新 types.ts、builder.ts、config-loader.ts 适配新路径 - 更新 probe-config.schema.json、probes.example.yaml、README.md、 DEVELOPMENT.md - 补充 config-loader 和 variables 测试覆盖新路径和旧入口拒绝 - 同步 5 个 delta specs 到主规范 (probe-config, config-variables, data-retention, probe-engine, runtime-logging) - 归档 openspec change reorganize-config-layout
403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
import { isNumber, isPlainObject, isString } from "es-toolkit";
|
|
import { dirname, resolve } from "node:path";
|
|
|
|
import type { ConfigValidationIssue } from "./schema/issues";
|
|
import type {
|
|
DefaultsConfig,
|
|
ExecutionConfig,
|
|
LoggingConfig,
|
|
LogLevel,
|
|
RawTargetConfig,
|
|
ResolvedLoggingConfig,
|
|
ResolvedTargetBase,
|
|
RotationFrequency,
|
|
ServerStorageConfig,
|
|
} from "./types";
|
|
|
|
import { checkerRegistry } from "./runner";
|
|
import { issue, throwConfigIssues } from "./schema/issues";
|
|
import { asValidatedConfig, type RawProbeConfig } from "./schema/types";
|
|
import { validateProbeConfigContract } from "./schema/validate";
|
|
import { parseDuration, parseSize } from "./utils";
|
|
import { resolveVariables } from "./variables";
|
|
|
|
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;
|
|
const DEFAULT_RETENTION = "7d";
|
|
const DEFAULT_LOG_LEVEL: LogLevel = "info";
|
|
const DEFAULT_ROTATION_SIZE = "50MB";
|
|
const DEFAULT_ROTATION_FREQUENCY: RotationFrequency = "daily";
|
|
const DEFAULT_ROTATION_MAX_FILES = 14;
|
|
|
|
const VALID_LOG_LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
|
const VALID_ROTATION_FREQUENCIES: RotationFrequency[] = ["hourly", "daily", "weekly"];
|
|
|
|
export interface ResolvedConfig {
|
|
configDir: string;
|
|
dataDir: string;
|
|
host: string;
|
|
logging: ResolvedLoggingConfig;
|
|
maxConcurrentChecks: number;
|
|
port: number;
|
|
retentionMs: number;
|
|
targets: ResolvedTargetBase[];
|
|
}
|
|
|
|
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 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 ?? resolvedVariablesConfig) 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 listen = server.listen ?? {};
|
|
const storage = server.storage ?? {};
|
|
const defaults = validated.defaults ?? {};
|
|
|
|
const host = listen.host ?? DEFAULT_HOST;
|
|
const port = listen.port ?? DEFAULT_PORT;
|
|
const dataDir = resolve(configDir, storage.dataDir ?? DEFAULT_DATA_DIR);
|
|
|
|
const probes = validated.probes ?? {};
|
|
const execution = probes.execution ?? {};
|
|
const maxConcurrentChecks = resolveMaxConcurrentChecks(execution);
|
|
const retentionMs = resolveRetention(storage);
|
|
|
|
const logging = resolveLogging(server.logging ?? {}, dataDir, configDir);
|
|
|
|
const allRuntimeIssues = [...allIssues];
|
|
validateLoggingConfig(server.logging, allRuntimeIssues);
|
|
if (allRuntimeIssues.length > 0) {
|
|
throwConfigIssues(dedupeIssues(allRuntimeIssues));
|
|
}
|
|
|
|
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
|
|
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT);
|
|
|
|
const targets: ResolvedTargetBase[] = validated.targets.map((target) =>
|
|
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
|
|
);
|
|
|
|
return { configDir, dataDir, host, logging, maxConcurrentChecks, port, retentionMs, targets };
|
|
}
|
|
|
|
function canRunSemanticValidation(value: unknown): boolean {
|
|
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 ?? ""}:${item.targetId ?? ""}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
result.push(item);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export { parseDuration } from "./utils";
|
|
|
|
function isAbsolute(p: string): boolean {
|
|
return p.startsWith("/") || /^[A-Za-z]:/.test(p);
|
|
}
|
|
|
|
function resolveLogging(logging: LoggingConfig, dataDir: string, configDir: string): ResolvedLoggingConfig {
|
|
const globalLevel = resolveLogLevel(logging.level, DEFAULT_LOG_LEVEL);
|
|
const consoleLevel = resolveLogLevel(logging.console?.level, globalLevel);
|
|
const fileLevel = resolveLogLevel(logging.file?.level, globalLevel);
|
|
|
|
const rawPath = logging.file?.path;
|
|
const filePath = rawPath
|
|
? isAbsolute(rawPath)
|
|
? rawPath
|
|
: resolve(configDir, rawPath)
|
|
: resolve(dataDir, "logs/dial.log");
|
|
|
|
const rotationRaw = logging.file?.rotation;
|
|
const rotationSizeRaw = rotationRaw?.size ?? DEFAULT_ROTATION_SIZE;
|
|
const rotationSizeBytes = parseSize(rotationSizeRaw);
|
|
const rotationFrequency = rotationRaw?.frequency ?? DEFAULT_ROTATION_FREQUENCY;
|
|
const rotationMaxFiles = rotationRaw?.maxFiles ?? DEFAULT_ROTATION_MAX_FILES;
|
|
|
|
return {
|
|
consoleLevel,
|
|
fileLevel,
|
|
filePath,
|
|
rotationFrequency,
|
|
rotationMaxFiles,
|
|
rotationSizeBytes,
|
|
rotationSizeRaw,
|
|
};
|
|
}
|
|
|
|
function resolveLogLevel(level: unknown, fallback: LogLevel): LogLevel {
|
|
if (!isString(level)) return fallback;
|
|
if (VALID_LOG_LEVELS.includes(level as LogLevel)) return level as LogLevel;
|
|
return fallback;
|
|
}
|
|
|
|
function resolveMaxConcurrentChecks(execution: ExecutionConfig): number {
|
|
if (execution.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
|
|
if (
|
|
!isNumber(execution.maxConcurrentChecks) ||
|
|
!Number.isInteger(execution.maxConcurrentChecks) ||
|
|
execution.maxConcurrentChecks <= 0
|
|
)
|
|
return DEFAULT_MAX_CONCURRENT_CHECKS;
|
|
return execution.maxConcurrentChecks;
|
|
}
|
|
|
|
function resolveRetention(storage: ServerStorageConfig): number {
|
|
return parseDuration(storage.retention ?? DEFAULT_RETENTION);
|
|
}
|
|
|
|
function resolveTarget(
|
|
target: RawTargetConfig,
|
|
defaults: DefaultsConfig,
|
|
defaultIntervalMs: number,
|
|
defaultTimeoutMs: number,
|
|
configDir: string,
|
|
): ResolvedTargetBase {
|
|
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;
|
|
result.description = target.description ?? null;
|
|
|
|
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 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 (!isPlainObject(rawTarget)) {
|
|
issues.push(issue("invalid-type", `targets[${i}]`, "必须为对象"));
|
|
continue;
|
|
}
|
|
const raw = rawTarget as Record<string, unknown>;
|
|
|
|
const id: unknown = raw["id"];
|
|
if (!isString(id) || id.trim() === "") {
|
|
issues.push(issue("required", `targets[${i}].id`, "缺少 id 字段"));
|
|
continue;
|
|
}
|
|
|
|
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;
|
|
|
|
if (isString(nameValue) && nameValue.trim() === "") {
|
|
issues.push(issue("invalid-value", `targets[${i}].name`, "name 不能为空白", name));
|
|
}
|
|
|
|
const type: unknown = raw["type"];
|
|
if (!isString(type)) {
|
|
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: unknown = raw["group"];
|
|
if (group !== undefined && !isString(group)) {
|
|
issues.push(issue("invalid-type", `targets[${i}].group`, "必须为字符串", name));
|
|
}
|
|
|
|
if (ids.has(id)) {
|
|
issues.push(issue("duplicate-id", `targets[${i}].id`, `target id 重复: "${id}"`, name));
|
|
}
|
|
|
|
ids.add(id);
|
|
}
|
|
|
|
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);
|
|
validateDurationValue(
|
|
isString(config.server?.storage?.retention) ? config.server.storage.retention : undefined,
|
|
"server.storage.retention",
|
|
issues,
|
|
);
|
|
for (let i = 0; i < config.targets.length; i++) {
|
|
const target = config.targets[i] as unknown;
|
|
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(
|
|
isString(targetRecord["interval"]) ? targetRecord["interval"] : undefined,
|
|
`targets[${i}].interval`,
|
|
issues,
|
|
targetName,
|
|
);
|
|
validateDurationValue(
|
|
isString(targetRecord["timeout"]) ? targetRecord["timeout"] : undefined,
|
|
`targets[${i}].timeout`,
|
|
issues,
|
|
targetName,
|
|
);
|
|
}
|
|
|
|
return issues;
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|
|
|
|
function validateLoggingConfig(logging: LoggingConfig | undefined, issues: ConfigValidationIssue[]): void {
|
|
if (logging === undefined) return;
|
|
|
|
if (logging.level !== undefined && !VALID_LOG_LEVELS.includes(logging.level)) {
|
|
issues.push(
|
|
issue(
|
|
"invalid-value",
|
|
"server.logging.level",
|
|
`日志等级非法: "${logging.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (logging.console?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.console.level)) {
|
|
issues.push(
|
|
issue(
|
|
"invalid-value",
|
|
"server.logging.console.level",
|
|
`日志等级非法: "${logging.console.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (logging.file?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.file.level)) {
|
|
issues.push(
|
|
issue(
|
|
"invalid-value",
|
|
"server.logging.file.level",
|
|
`日志等级非法: "${logging.file.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (logging.file?.path !== undefined) {
|
|
if (!isString(logging.file.path) || logging.file.path.trim() === "") {
|
|
issues.push(issue("invalid-value", "server.logging.file.path", "日志路径不能为空字符串或空白字符串"));
|
|
}
|
|
}
|
|
|
|
const rotation = logging.file?.rotation;
|
|
if (rotation?.size !== undefined) {
|
|
try {
|
|
const bytes = parseSize(rotation.size);
|
|
if (bytes <= 0) {
|
|
issues.push(issue("invalid-value", "server.logging.file.rotation.size", "滚动大小必须为正整数字节数"));
|
|
}
|
|
} catch (error) {
|
|
issues.push(
|
|
issue(
|
|
"invalid-value",
|
|
"server.logging.file.rotation.size",
|
|
error instanceof Error ? error.message : "size 格式非法",
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (rotation?.frequency !== undefined && !VALID_ROTATION_FREQUENCIES.includes(rotation.frequency)) {
|
|
issues.push(
|
|
issue(
|
|
"invalid-value",
|
|
"server.logging.file.rotation.frequency",
|
|
`滚动频率非法: "${rotation.frequency}",支持: ${VALID_ROTATION_FREQUENCIES.join(", ")}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (rotation?.maxFiles !== undefined) {
|
|
if (!isNumber(rotation.maxFiles) || !Number.isInteger(rotation.maxFiles) || rotation.maxFiles <= 0) {
|
|
issues.push(issue("invalid-value", "server.logging.file.rotation.maxFiles", "maxFiles 必须为正整数"));
|
|
}
|
|
}
|
|
}
|