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 { 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(); 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(); 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; 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; 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 必须为正整数")); } } }