1
0

feat: 重构配置布局,server.listen/storage/logging + probes.execution 分组

- 新增 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
This commit is contained in:
2026-05-21 13:54:41 +08:00
parent 5238dbe77d
commit e448cb4654
14 changed files with 614 additions and 376 deletions

View File

@@ -140,7 +140,7 @@ probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动
dev.ts / main.ts → readRuntimeConfig(cli args, 仅提取 configPath)
→ bootstrap({ configPath, mode })
→ loadConfig(yamlYAML 解析 → 变量替换 → 契约校验 → 语义校验 → resolve)
→ ResolvedConfig{ host, port, dataDir, maxConcurrentChecks, retentionMs, targets, logging }
→ ResolvedConfig{ host(server.listen), port(server.listen), dataDir(server.storage), maxConcurrentChecks(probes.execution), retentionMs(server.storage), targets, logging(server.logging) }
→ createRuntimeLogger(logging) → Logger配置加载失败时使用 ConsoleFallbackLogger
→ ProbeStore(db) → store.syncTargets(targets)
→ ProbeEngine(store, targets, maxConcurrentChecks, retentionMs, logger) → engine.start()
@@ -254,16 +254,16 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode:
`ResolvedConfig` 包含以下字段:
| 字段 | 来源 | 默认值 |
| --------------------- | -------------------------------------------------- | ---------------- |
| `configDir` | 配置文件所在目录 | — |
| `dataDir` | `server.dataDir`(基于配置文件目录解析为绝对路径) | `configDir/data` |
| `host` | `server.host` | `127.0.0.1` |
| `logging` | `logging`(等级继承、路径解析、滚动参数) | 见 logging 配置 |
| `port` | `server.port` | `3000` |
| `maxConcurrentChecks` | `runtime.maxConcurrentChecks` | `20` |
| `retentionMs` | `runtime.retention` | `7d` |
| `targets` | `targets[]` 经 resolve 后 | — |
| 字段 | 来源 | 默认值 |
| --------------------- | ---------------------------------------------------------- | ---------------- |
| `configDir` | 配置文件所在目录 | — |
| `dataDir` | `server.storage.dataDir`(基于配置文件目录解析为绝对路径) | `configDir/data` |
| `host` | `server.listen.host` | `127.0.0.1` |
| `logging` | `server.logging`(等级继承、路径解析、滚动参数) | 见 logging 配置 |
| `port` | `server.listen.port` | `3000` |
| `maxConcurrentChecks` | `probes.execution.maxConcurrentChecks` | `20` |
| `retentionMs` | `server.storage.retention` | `7d` |
| `targets` | `targets[]` 经 resolve 后 | — |
契约层使用 `src/server/checker/schema/` 中的 TypeBox fragments 生成 JSON Schema并用 Ajv 执行启动期校验。Ajv 必须保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。

View File

@@ -117,13 +117,20 @@ dist/release/
# yaml-language-server: $schema=./probe-config.schema.json
server: # 服务配置(均可省略)
host: "127.0.0.1"
port: 3000
dataDir: "/tmp/probes_data"
listen:
host: "127.0.0.1"
port: 3000
storage:
dataDir: "/tmp/probes_data"
retention: "7d"
logging:
level: "info"
file:
path: "<dataDir>/logs/dial.log"
runtime: # 运行时配置
maxConcurrentChecks: 20
retention: "7d"
probes: # 拨测运行时配置(可省略)
execution:
maxConcurrentChecks: 20
variables: # 配置变量(可省略)
env_name: "生产"
@@ -154,32 +161,37 @@ targets: # 拨测目标列表(必填)
# ... 更多 targets
```
### server — 服务配置
### server.listen监听配置
| 字段 | 说明 | 必填 | 默认值 |
| --------- | ------------------------------------------ | ---- | ----------- |
| `host` | 监听地址 | 否 | `127.0.0.1` |
| `port` | 监听端口 | 否 | `3000` |
| `dataDir` | 数据目录,相对路径基于配置文件所在目录解析 | 否 | `./data` |
| 字段 | 说明 | 必填 | 默认值 |
| ------ | -------- | ---- | ----------- |
| `host` | 监听地址 | 否 | `127.0.0.1` |
| `port` | 监听端口 | 否 | `3000` |
### runtime — 运行时配置
### server.storage — 存储配置
| 字段 | 说明 | 必填 | 默认值 |
| --------------------- | ------------------------------------------------ | ---- | ------ |
| `maxConcurrentChecks` | 最大并发拨测数 | 否 | `20` |
| `retention` | 历史数据保留时长,支持 `ms`/`s`/`m`/`h`/`d` 单位 | 否 | `7d` |
| 字段 | 说明 | 必填 | 默认值 |
| ----------- | ------------------------------------------------ | ---- | -------- |
| `dataDir` | 数据目录,相对路径基于配置文件所在目录解析 | 否 | `./data` |
| `retention` | 历史数据保留时长,支持 `ms`/`s`/`m`/`h`/`d` 单位 | 否 | `7d` |
### logging — 日志配置
### probes.execution — 拨测运行时配置
| 字段 | 说明 | 必填 | 默认值 |
| ------------------------- | ---------------------------------------------- | ---- | ------------------------- |
| `level` | 全局日志等级console 和 file 未指定时继承此值 | 否 | `info` |
| `console.level` | 控制台日志等级 | 否 | 继承 `level` |
| `file.level` | 文件日志等级 | 否 | 继承 `level` |
| `file.path` | 日志文件路径,相对路径基于配置文件目录解析 | 否 | `<dataDir>/logs/dial.log` |
| `file.rotation.size` | 按大小滚动,支持 `KB`/`MB`/`GB` 单位 | 否 | `50MB` |
| `file.rotation.frequency` | 按时间滚动:`hourly``daily``weekly` | 否 | `daily` |
| `file.rotation.maxFiles` | 保留的归档文件数量(不含活跃日志) | 否 | `14` |
| 字段 | 说明 | 必填 | 默认值 |
| --------------------- | -------------- | ---- | ------ |
| `maxConcurrentChecks` | 最大并发拨测数 | 否 | `20` |
### server.logging — 日志配置
| 字段 | 说明 | 必填 | 默认值 |
| ---------------------------------------- | ---------------------------------------------- | ---- | ------------------------- |
| `server.logging.level` | 全局日志等级console 和 file 未指定时继承此值 | 否 | `info` |
| `server.logging.console.level` | 控制台日志等级 | 否 | 继承 `level` |
| `server.logging.file.level` | 文件日志等级 | 否 | 继承 `level` |
| `server.logging.file.path` | 日志文件路径,相对路径基于配置文件目录解析 | 否 | `<dataDir>/logs/dial.log` |
| `server.logging.file.rotation.size` | 按大小滚动,支持 `KB`/`MB`/`GB` 单位 | 否 | `50MB` |
| `server.logging.file.rotation.frequency` | 按时间滚动:`hourly``daily``weekly` | 否 | `daily` |
| `server.logging.file.rotation.maxFiles` | 保留的归档文件数量(不含活跃日志) | 否 | `14` |
日志等级支持:`trace``debug``info``warn``error``fatal`

View File

@@ -121,7 +121,7 @@ targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHA
- **THEN** 系统 SHALL 将该字段替换为 string 类型的 "localhost"
### Requirement: 替换范围限制
变量替换 SHALL 仅作用于 targets 段。`id``type` 字段 MUST NOT 参与变量替换。`server``runtime``defaults` 段 MUST NOT 参与变量替换。系统 SHALL 递归遍历 target 对象树中所有字符串值进行替换(包括嵌套对象和数组元素中的字符串)。
变量替换 SHALL 仅作用于 targets 段。`id``type` 字段 MUST NOT 参与变量替换。`server``probes``defaults` 段 MUST NOT 参与变量替换。系统 SHALL 递归遍历 target 对象树中所有字符串值进行替换(包括嵌套对象和数组元素中的字符串)。
#### Scenario: target 嵌套对象中的变量替换
- **WHEN** target 配置 `http.headers.Authorization: "${token}"` 且 variables 中定义 `token: "Bearer abc"`
@@ -144,8 +144,12 @@ targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHA
- **THEN** 系统 SHALL 保持 defaults.interval 为字面量 `"${default_interval}"`,不进行替换
#### Scenario: server 段不替换
- **WHEN** server 配置 `host: "${server_host}"` 且 variables 中定义 `server_host: "0.0.0.0"`
- **THEN** 系统 SHALL 保持 server.host 为字面量 `"${server_host}"`,不进行替换
- **WHEN** server 配置 `listen.host: "${server_host}"` 且 variables 中定义 `server_host: "0.0.0.0"`
- **THEN** 系统 SHALL 保持 server.listen.host 为字面量 `"${server_host}"`,不进行替换
#### Scenario: probes 段不替换
- **WHEN** probes 配置 `execution.maxConcurrentChecks: "${max_checks}"` 且 variables 中定义 `max_checks: 5`
- **THEN** 系统 SHALL 保持 probes.execution.maxConcurrentChecks 为字面量 `"${max_checks}"`,不进行替换
### Requirement: 变量替换错误报告
变量替换阶段的错误 SHALL 作为 `ConfigValidationIssue` 输出code 为 `unresolved-variable`。错误信息 SHALL 包含 target 索引、target id、字段路径和变量名。

View File

@@ -5,22 +5,22 @@
## Requirements
### Requirement: 数据保留配置
系统 SHALL 支持通过 `runtime.retention` 配置项指定历史数据保留时长,格式为持续时间字符串(`<数字><单位>`,单位支持 `d`/`h`/`m`)。
系统 SHALL 支持通过 `server.storage.retention` 配置项指定历史数据保留时长,格式为持续时间字符串(`<数字><单位>`,单位支持 `d`/`h`/`m`)。
#### Scenario: 配置 7 天保留
- **WHEN** 配置文件中 `runtime.retention` 设置为 `"7d"`
- **WHEN** 配置文件中 `server.storage.retention` 设置为 `"7d"`
- **THEN** 系统 SHALL 保留最近 7 天的检查结果,清理更早的数据
#### Scenario: 配置小时级保留
- **WHEN** 配置文件中 `runtime.retention` 设置为 `"24h"`
- **WHEN** 配置文件中 `server.storage.retention` 设置为 `"24h"`
- **THEN** 系统 SHALL 保留最近 24 小时的检查结果
#### Scenario: 未配置 retention
- **WHEN** 配置文件中未指定 `runtime.retention`
- **WHEN** 配置文件中未指定 `server.storage.retention`
- **THEN** 系统 SHALL 使用默认值 `"7d"`
#### Scenario: 无效 retention 格式
- **WHEN** 配置文件中 `runtime.retention` 格式不合法(如 `"abc"``"7x"`
- **WHEN** 配置文件中 `server.storage.retention` 格式不合法(如 `"abc"``"7x"`
- **THEN** 系统 SHALL 在配置校验阶段报错,拒绝启动
### Requirement: 定时清理调度

View File

@@ -4,18 +4,37 @@
## Requirements
### Requirement: 旧配置入口拒绝
系统 SHALL 拒绝旧版顶层 `runtime` 和顶层 `logging` 配置入口。系统 SHALL 拒绝旧版 `server.host``server.port``server.dataDir` 入口,并要求使用 `server.listen``server.storage` 下的新路径。
#### Scenario: 顶层 runtime 被拒绝
- **WHEN** 配置文件声明顶层 `runtime`
- **THEN** 系统 SHALL 以配置错误退出并提示存在未知字段 `runtime`
#### Scenario: 顶层 logging 被拒绝
- **WHEN** 配置文件声明顶层 `logging`
- **THEN** 系统 SHALL 以配置错误退出并提示存在未知字段 `logging`
#### Scenario: server 旧监听字段被拒绝
- **WHEN** 配置文件声明 `server.host``server.port`
- **THEN** 系统 SHALL 以配置错误退出并提示 `server` 中存在未知字段
#### Scenario: server 旧 dataDir 字段被拒绝
- **WHEN** 配置文件声明 `server.dataDir`
- **THEN** 系统 SHALL 以配置错误退出并提示 `server` 中存在未知字段 `dataDir`
### Requirement: YAML 配置文件格式
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段。target MUST 使用 `id` 字段作为唯一标识符MUST 使用 `type` 字段声明 checker 类型SHALL 支持可选的 `name` 字段作为展示名称元信息SHALL 支持可选的 `description` 字段作为目标说明。`name``description` 均 SHALL 允许省略或显式配置为 `null`;省略或显式 null 时解析结果 SHALL 保留为 null。HTTP 领域字段 MUST 放在 `http` 分组cmd 领域字段 MUST 放在 `cmd` 分组db 领域字段 MUST 放在 `db` 分组tcp 领域字段 MUST 放在 `tcp` 分组icmp 领域字段 MUST 放在 `icmp` 分组udp 领域字段 MUST 放在 `udp` 分组LLM 领域字段 MUST 放在 `llm` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`非负整数字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`可选字段。Tcp target 的 `tcp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`readBanner`(可选)、`bannerReadTimeout`(可选)和 `maxBannerBytes`可选字段。Icmp target 的 `icmp` 分组 SHALL 支持 `host`(必填)、`count`(可选,默认 3`packetSize`(可选,默认 56字段。Udp target 的 `udp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`payload`(可选,默认空字符串)、`encoding`(可选,默认 `text`)、`responseEncoding`(可选,默认 `text`)和 `maxResponseBytes`(可选,默认 4096字段。LLM target 的 `llm` 分组 SHALL 支持 `provider`(必填)、`url`(必填)、`model`(必填)、`prompt`(必填)、`mode`(可选,默认 `http`)、`key`(可选,默认空字符串)、`authToken`(可选)、`headers`(可选)、`ignoreSSL`(可选,默认 `false`)、`options`(可选)和 `providerOptions`(可选)字段。
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、probes 执行配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段)。server 配置 SHALL 将 HTTP 监听参数放在 `server.listen` 分组,将本地数据目录和历史数据保留时长放在 `server.storage` 分组,将运行时日志配置放在 `server.logging` 分组。拨测全局执行策略 SHALL 放在 `probes.execution` 分组。target MUST 使用 `id` 字段作为唯一标识符MUST 使用 `type` 字段声明 checker 类型SHALL 支持可选的 `name` 字段作为展示名称元信息SHALL 支持可选的 `description` 字段作为目标说明。`name``description` 均 SHALL 允许省略或显式配置为 `null`;省略或显式 null 时解析结果 SHALL 保留为 null。HTTP 领域字段 MUST 放在 `http` 分组cmd 领域字段 MUST 放在 `cmd` 分组db 领域字段 MUST 放在 `db` 分组tcp 领域字段 MUST 放在 `tcp` 分组icmp 领域字段 MUST 放在 `icmp` 分组udp 领域字段 MUST 放在 `udp` 分组LLM 领域字段 MUST 放在 `llm` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`非负整数字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`可选字段。Tcp target 的 `tcp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`readBanner`(可选)、`bannerReadTimeout`(可选)和 `maxBannerBytes`可选字段。Icmp target 的 `icmp` 分组 SHALL 支持 `host`(必填)、`count`(可选,默认 3`packetSize`(可选,默认 56字段。Udp target 的 `udp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`payload`(可选,默认空字符串)、`encoding`(可选,默认 `text`)、`responseEncoding`(可选,默认 `text`)和 `maxResponseBytes`(可选,默认 4096字段。LLM target 的 `llm` 分组 SHALL 支持 `provider`(必填)、`url`(必填)、`model`(必填)、`prompt`(必填)、`mode`(可选,默认 `http`)、`key`(可选,默认空字符串)、`authToken`(可选)、`headers`(可选)、`ignoreSSL`(可选,默认 `false`)、`options`(可选)和 `providerOptions`(可选)字段。
`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。`defaults.tcp` 分组 SHALL 仅支持 `bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。`defaults.icmp` 分组 SHALL 仅支持空对象。`defaults.udp` 分组 SHALL 仅支持 `encoding`(可选)、`responseEncoding`(可选)和 `maxResponseBytes`(可选)字段。`defaults.llm` 分组 SHALL 仅支持 `mode`(可选)、`headers`(可选)、`ignoreSSL`(可选)、`options`(可选)和 `providerOptions`(可选)字段。
#### Scenario: 完整配置文件解析
- **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targets含 id、group 字段)的 YAML 配置文件
- **WHEN** 系统启动并读取包含 server.listen、server.storage、server.logging、probes.execution、variables、defaults、targets含 id、group 字段)的 YAML 配置文件
- **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务、调度引擎和对应 checker runner
#### Scenario: 最简 HTTP 配置文件解析
- **WHEN** 系统读取只包含一个 `type: http` target`id``http.url`)的 YAML 配置文件(省略 server、runtime、variables、defaults 和 expect
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB, http.ignoreSSL=false, http.maxRedirects=0, group="default"),并保留 name=null、description=null
- **WHEN** 系统读取只包含一个 `type: http` target`id``http.url`)的 YAML 配置文件(省略 server、probes、variables、defaults 和 expect
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段host=127.0.0.1, port=3000, dataDir=./data, interval=30s, timeout=10s, probes.execution.maxConcurrentChecks=20, server.storage.retention=7d, http.method=GET, http.maxBodyBytes=100MB, http.ignoreSSL=false, http.maxRedirects=0, group="default"),并保留 name=null、description=null
#### Scenario: 最简 cmd 配置文件解析
- **WHEN** 系统读取只包含一个 `type: cmd` target`id``cmd.exec`)的 YAML 配置文件
@@ -136,8 +155,8 @@
- **THEN** 系统 SHALL 以错误退出并提示格式错误
#### Scenario: maxConcurrentChecks 非法
- **WHEN** runtime.maxConcurrentChecks 不是正整数
- **THEN** 系统 SHALL 以错误退出并提示 runtime.maxConcurrentChecks 格式错误
- **WHEN** probes.execution.maxConcurrentChecks 不是正整数
- **THEN** 系统 SHALL 以错误退出并提示 probes.execution.maxConcurrentChecks 格式错误
#### Scenario: interval 或 timeout 解析结果非法
- **WHEN** interval 或 timeout 解析结果不是正整数毫秒(如 `0ms``1.5ms`
@@ -325,14 +344,14 @@
- **THEN** 系统 SHALL 将其解析为 524288 bytes
### Requirement: runtime 并发配置
系统 SHALL 支持 `runtime.maxConcurrentChecks` 配置全局最大并发检查数。
系统 SHALL 支持 `probes.execution.maxConcurrentChecks` 配置全局最大并发检查数。`probes``probes.execution` 配置段均 SHALL 为可选,省略时使用默认值。
#### Scenario: 使用默认并发限制
- **WHEN** YAML 中未配置 runtime.maxConcurrentChecks
- **WHEN** YAML 中未配置 `probes` 或未配置 `probes.execution.maxConcurrentChecks`
- **THEN** 系统 SHALL 使用默认值 20
#### Scenario: 配置并发限制
- **WHEN** YAML 中配置 `runtime.maxConcurrentChecks: 5`
- **WHEN** YAML 中配置 `probes.execution.maxConcurrentChecks: 5`
- **THEN** 系统 SHALL 将全局最大并发检查数设置为 5
### Requirement: YAML 配置使用 Bun 内置解析
@@ -428,33 +447,33 @@
- **THEN** `targets.expect` SHALL 存储变量替换后的 Raw expect JSON而不是包含 `kind` 或 resolved matcher 的运行期执行计划
### Requirement: 数据保留配置字段
配置 schema 的 `runtime` 段 SHALL 支持 `retention` 字段,类型为字符串,格式为 `<数字><单位>`(单位:`d` 天、`h` 小时、`m` 分钟),用于指定历史数据保留时长。
配置 schema 的 `server.storage` 段 SHALL 支持 `retention` 字段,类型为字符串,格式为 `<数字><单位>`(单位:`d` 天、`h` 小时、`m` 分钟),用于指定历史数据保留时长。
#### Scenario: retention 字段校验通过
- **WHEN** 配置文件中 `runtime.retention` 为合法格式(如 `"7d"``"24h"``"30m"`
- **WHEN** 配置文件中 `server.storage.retention` 为合法格式(如 `"7d"``"24h"``"30m"`
- **THEN** 配置校验 SHALL 通过
#### Scenario: retention 字段格式非法
- **WHEN** 配置文件中 `runtime.retention` 为非法格式(如 `"abc"``"7x"``""`
- **WHEN** 配置文件中 `server.storage.retention` 为非法格式(如 `"abc"``"7x"``""`
- **THEN** 配置校验 SHALL 失败并报告格式错误
#### Scenario: retention 字段缺省
- **WHEN** 配置文件中未指定 `runtime.retention`
- **WHEN** 配置文件中未指定 `server.storage.retention`
- **THEN** 系统 SHALL 使用默认值 `"7d"`
### Requirement: 数据目录路径解析
配置加载流程 SHALL 将 `server.dataDir` 相对路径基于配置文件所在目录configDir解析为绝对路径。绝对路径 SHALL 保持不变。
配置加载流程 SHALL 将 `server.storage.dataDir` 相对路径基于配置文件所在目录configDir解析为绝对路径。绝对路径 SHALL 保持不变。
#### Scenario: dataDir 为相对路径
- **WHEN** 配置文件位于 `/opt/dial/probes.yaml`,且 `server.dataDir` 配置为 `./data`
- **WHEN** 配置文件位于 `/opt/dial/probes.yaml`,且 `server.storage.dataDir` 配置为 `./data`
- **THEN** 系统 SHALL 将 dataDir 解析为 `/opt/dial/data`,而非依赖进程 cwd
#### Scenario: dataDir 为绝对路径
- **WHEN** `server.dataDir` 配置为 `/var/lib/dial/data`
- **WHEN** `server.storage.dataDir` 配置为 `/var/lib/dial/data`
- **THEN** 系统 SHALL 直接使用该绝对路径,不做额外解析
#### Scenario: dataDir 使用默认值
- **WHEN** 未配置 `server.dataDir`(使用默认值 `./data`
- **WHEN** 未配置 `server.storage.dataDir`(使用默认值 `./data`
- **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径
### Requirement: target 通用元信息字段约束
@@ -634,51 +653,51 @@
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.stream.firstTokenMs 格式错误
### Requirement: 日志配置格式
系统 SHALL 支持可选的顶层 `logging` 配置,用于定义运行时日志等级、命令行日志等级、文件日志等级、文件路径和滚动策略。`logging` 未配置时 SHALL 使用内置默认值。系统 SHALL NOT 支持 `logging.console.enabled``logging.console.format``logging.file.enabled``logging.file.format``logging.file.rotation.enabled` 字段。
系统 SHALL 支持可选的 `server.logging` 配置,用于定义运行时日志等级、命令行日志等级、文件日志等级、文件路径和滚动策略。`server.logging` 未配置时 SHALL 使用内置默认值。系统 SHALL NOT 支持 `server.logging.console.enabled``server.logging.console.format``server.logging.file.enabled``server.logging.file.format``server.logging.file.rotation.enabled` 字段。
#### Scenario: 未配置 logging 使用默认值
- **WHEN** 配置文件未声明 `logging`
- **THEN** 系统 SHALL 使用 `logging.level=info``logging.console.level=info``logging.file.level=info``logging.file.path=<resolved dataDir>/logs/dial.log``logging.file.rotation.size=50MB``logging.file.rotation.frequency=daily``logging.file.rotation.maxFiles=14`
- **WHEN** 配置文件未声明 `server.logging`
- **THEN** 系统 SHALL 使用 `server.logging.level=info``server.logging.console.level=info``server.logging.file.level=info``server.logging.file.path=<resolved dataDir>/logs/dial.log``server.logging.file.rotation.size=50MB``server.logging.file.rotation.frequency=daily``server.logging.file.rotation.maxFiles=14`
#### Scenario: console 和 file level 继承全局 level
- **WHEN** 配置声明 `logging.level: warn` 且未声明 `logging.console.level``logging.file.level`
- **WHEN** 配置声明 `server.logging.level: warn` 且未声明 `server.logging.console.level``server.logging.file.level`
- **THEN** 系统 SHALL 将 console 和 file 的日志等级均解析为 `warn`
#### Scenario: 显式配置文件日志路径
- **WHEN** 配置声明 `logging.file.path`
- **WHEN** 配置声明 `server.logging.file.path`
- **THEN** 系统 SHALL 使用该路径作为文件日志路径,而不是默认 `<resolved dataDir>/logs/dial.log`
#### Scenario: 相对日志路径
- **WHEN** `logging.file.path` 是相对路径
- **WHEN** `server.logging.file.path` 是相对路径
- **THEN** 系统 SHALL 基于配置文件所在目录解析为绝对路径
#### Scenario: 绝对日志路径
- **WHEN** `logging.file.path` 是绝对路径
- **WHEN** `server.logging.file.path` 是绝对路径
- **THEN** 系统 SHALL 原样使用该绝对路径,并允许该路径位于 `dataDir` 之外
#### Scenario: 不支持日志开关和格式字段
- **WHEN** 配置声明 `logging.console.enabled``logging.console.format``logging.file.enabled``logging.file.format``logging.file.rotation.enabled`
- **WHEN** 配置声明 `server.logging.console.enabled``server.logging.console.format``server.logging.file.enabled``server.logging.file.format``server.logging.file.rotation.enabled`
- **THEN** 系统 SHALL 以配置错误退出并提示存在未知字段
### Requirement: 日志配置校验
系统 SHALL 在启动期校验 `logging` 配置。日志等级 SHALL 只能是 `trace``debug``info``warn``error``fatal``rotation.size` SHALL 使用有效 size 格式且解析为正整数字节数。`rotation.frequency` SHALL 只能是 `hourly``daily``weekly``rotation.maxFiles` SHALL 是正整数。
系统 SHALL 在启动期校验 `server.logging` 配置。日志等级 SHALL 只能是 `trace``debug``info``warn``error``fatal``rotation.size` SHALL 使用有效 size 格式且解析为正整数字节数。`rotation.frequency` SHALL 只能是 `hourly``daily``weekly``rotation.maxFiles` SHALL 是正整数。
#### Scenario: 非法日志等级
- **WHEN** 配置声明 `logging.level: verbose`
- **WHEN** 配置声明 `server.logging.level: verbose`
- **THEN** 系统 SHALL 以配置错误退出并提示日志等级非法
#### Scenario: 非法滚动大小
- **WHEN** 配置声明 `logging.file.rotation.size: "large"`
- **WHEN** 配置声明 `server.logging.file.rotation.size: "large"`
- **THEN** 系统 SHALL 以配置错误退出并提示 size 格式非法
#### Scenario: 非法滚动频率
- **WHEN** 配置声明 `logging.file.rotation.frequency: monthly`
- **WHEN** 配置声明 `server.logging.file.rotation.frequency: monthly`
- **THEN** 系统 SHALL 以配置错误退出并提示 frequency 非法
#### Scenario: 非法归档数量
- **WHEN** 配置声明 `logging.file.rotation.maxFiles: 0`
- **WHEN** 配置声明 `server.logging.file.rotation.maxFiles: 0`
- **THEN** 系统 SHALL 以配置错误退出并提示 maxFiles 必须为正整数
#### Scenario: 非法日志路径
- **WHEN** 配置声明 `logging.file.path` 为空字符串或空白字符串
- **WHEN** 配置声明 `server.logging.file.path` 为空字符串或空白字符串
- **THEN** 系统 SHALL 以配置错误退出并提示日志路径非法

View File

@@ -16,7 +16,7 @@
- **THEN** 系统 SHALL 创建两个独立定时器,分别按各自频率调度
### Requirement: 组内并发拨测
系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制。当某个目标的 checker 执行 rejected非正常 CheckResult 返回,而是 Promise reject系统 SHALL 将该异常记录为 `matched: false` 的 check_result而非仅 console.warn。
系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `probes.execution.maxConcurrentChecks` 限制。当某个目标的 checker 执行 rejected非正常 CheckResult 返回,而是 Promise reject系统 SHALL 将该异常记录为 `matched: false` 的 check_result而非仅 console.warn。
#### Scenario: 同组目标并发执行
- **WHEN** 调度器触发一次 tick该组有 3 个目标,且全局并发余量至少为 3
@@ -35,7 +35,7 @@
- **THEN** 系统 SHALL 通过 Promise.allSettled 的索引关联回 target 数组,获取对应的 targetName 用于写入 check_result
#### Scenario: 全局并发限制生效
- **WHEN** 调度器同时触发 10 个目标且 runtime.maxConcurrentChecks 为 3
- **WHEN** 调度器同时触发 10 个目标且 probes.execution.maxConcurrentChecks 为 3
- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放
### Requirement: HTTP 拨测执行

View File

@@ -8,7 +8,7 @@
系统 SHALL 在配置解析成功后初始化统一运行时 logger。logger SHALL 同时输出命令行 pretty 日志和文件 JSONL 日志。命令行输出、文件输出和文件滚动 SHALL 始终启用,不提供关闭开关。
#### Scenario: 默认初始化 logger
- **WHEN** 配置文件未声明 `logging`
- **WHEN** 配置文件未声明 `server.logging`
- **THEN** 系统 SHALL 使用默认等级 `info` 初始化 console pretty 输出和 `<resolved dataDir>/logs/dial.log` 文件 JSONL 输出
#### Scenario: 模块 child logger
@@ -20,14 +20,14 @@
- **THEN** 系统 SHALL 通过正式 logger 输出 `fatal` 日志并以非零状态退出
### Requirement: 日志等级语义
系统 SHALL 支持 `trace``debug``info``warn``error``fatal` 六个日志等级。`logging.level` SHALL 作为全局默认等级,`logging.console.level``logging.file.level` SHALL 在省略时继承全局等级。
系统 SHALL 支持 `trace``debug``info``warn``error``fatal` 六个日志等级。`server.logging.level` SHALL 作为全局默认等级,`server.logging.console.level``server.logging.file.level` SHALL 在省略时继承全局等级。
#### Scenario: 目的地等级继承
- **WHEN** 配置只声明 `logging.level: warn`
- **WHEN** 配置只声明 `server.logging.level: warn`
- **THEN** console 和 file 输出均 SHALL 使用 `warn` 作为最低输出等级
#### Scenario: 目的地等级覆盖
- **WHEN** 配置声明 `logging.level: info``logging.console.level: warn``logging.file.level: debug`
- **WHEN** 配置声明 `server.logging.level: info``server.logging.console.level: warn``server.logging.file.level: debug`
- **THEN** console SHALL 输出 `warn` 及以上日志file SHALL 输出 `debug` 及以上日志
#### Scenario: 默认不输出 debug 检查摘要
@@ -35,7 +35,7 @@
- **THEN** 每次检查的 debug 摘要 SHALL NOT 输出到 console 或 file
### Requirement: 文件日志滚动
系统 SHALL 对文件日志启用滚动策略。滚动 SHALL 在 `logging.file.rotation.size``logging.file.rotation.frequency` 任一条件满足时触发。`logging.file.rotation.maxFiles` SHALL 表示最多保留的归档文件数量,不包含当前正在写入的日志文件。
系统 SHALL 对文件日志启用滚动策略。滚动 SHALL 在 `server.logging.file.rotation.size``server.logging.file.rotation.frequency` 任一条件满足时触发。`server.logging.file.rotation.maxFiles` SHALL 表示最多保留的归档文件数量,不包含当前正在写入的日志文件。
#### Scenario: 按大小滚动
- **WHEN** 当前日志文件达到配置的 `rotation.size`

View File

@@ -211,159 +211,19 @@
}
}
},
"logging": {
"probes": {
"additionalProperties": false,
"type": "object",
"properties": {
"console": {
"execution": {
"additionalProperties": false,
"type": "object",
"properties": {
"level": {
"anyOf": [
{
"const": "trace",
"type": "string"
},
{
"const": "debug",
"type": "string"
},
{
"const": "info",
"type": "string"
},
{
"const": "warn",
"type": "string"
},
{
"const": "error",
"type": "string"
},
{
"const": "fatal",
"type": "string"
}
]
"maxConcurrentChecks": {
"minimum": 1,
"type": "integer"
}
}
},
"file": {
"additionalProperties": false,
"type": "object",
"properties": {
"level": {
"anyOf": [
{
"const": "trace",
"type": "string"
},
{
"const": "debug",
"type": "string"
},
{
"const": "info",
"type": "string"
},
{
"const": "warn",
"type": "string"
},
{
"const": "error",
"type": "string"
},
{
"const": "fatal",
"type": "string"
}
]
},
"path": {
"minLength": 1,
"type": "string"
},
"rotation": {
"additionalProperties": false,
"type": "object",
"properties": {
"frequency": {
"anyOf": [
{
"const": "hourly",
"type": "string"
},
{
"const": "daily",
"type": "string"
},
{
"const": "weekly",
"type": "string"
}
]
},
"maxFiles": {
"minimum": 1,
"type": "integer"
},
"size": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
}
}
}
}
},
"level": {
"anyOf": [
{
"const": "trace",
"type": "string"
},
{
"const": "debug",
"type": "string"
},
{
"const": "info",
"type": "string"
},
{
"const": "warn",
"type": "string"
},
{
"const": "error",
"type": "string"
},
{
"const": "fatal",
"type": "string"
}
]
}
}
},
"runtime": {
"additionalProperties": false,
"type": "object",
"properties": {
"maxConcurrentChecks": {
"minimum": 1,
"type": "integer"
},
"retention": {
"type": "string"
}
}
},
@@ -371,16 +231,174 @@
"additionalProperties": false,
"type": "object",
"properties": {
"dataDir": {
"type": "string"
"listen": {
"additionalProperties": false,
"type": "object",
"properties": {
"host": {
"type": "string"
},
"port": {
"maximum": 65535,
"minimum": 0,
"type": "integer"
}
}
},
"host": {
"type": "string"
"logging": {
"additionalProperties": false,
"type": "object",
"properties": {
"console": {
"additionalProperties": false,
"type": "object",
"properties": {
"level": {
"anyOf": [
{
"const": "trace",
"type": "string"
},
{
"const": "debug",
"type": "string"
},
{
"const": "info",
"type": "string"
},
{
"const": "warn",
"type": "string"
},
{
"const": "error",
"type": "string"
},
{
"const": "fatal",
"type": "string"
}
]
}
}
},
"file": {
"additionalProperties": false,
"type": "object",
"properties": {
"level": {
"anyOf": [
{
"const": "trace",
"type": "string"
},
{
"const": "debug",
"type": "string"
},
{
"const": "info",
"type": "string"
},
{
"const": "warn",
"type": "string"
},
{
"const": "error",
"type": "string"
},
{
"const": "fatal",
"type": "string"
}
]
},
"path": {
"minLength": 1,
"type": "string"
},
"rotation": {
"additionalProperties": false,
"type": "object",
"properties": {
"frequency": {
"anyOf": [
{
"const": "hourly",
"type": "string"
},
{
"const": "daily",
"type": "string"
},
{
"const": "weekly",
"type": "string"
}
]
},
"maxFiles": {
"minimum": 1,
"type": "integer"
},
"size": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
}
}
}
}
},
"level": {
"anyOf": [
{
"const": "trace",
"type": "string"
},
{
"const": "debug",
"type": "string"
},
{
"const": "info",
"type": "string"
},
{
"const": "warn",
"type": "string"
},
{
"const": "error",
"type": "string"
},
{
"const": "fatal",
"type": "string"
}
]
}
}
},
"port": {
"maximum": 65535,
"minimum": 0,
"type": "integer"
"storage": {
"additionalProperties": false,
"type": "object",
"properties": {
"dataDir": {
"type": "string"
},
"retention": {
"type": "string"
}
}
}
}
},

View File

@@ -1,24 +1,26 @@
# yaml-language-server: $schema=./probe-config.schema.json
server:
host: "127.0.0.1"
port: 3000
dataDir: "/tmp/probes_data"
listen:
host: "127.0.0.1"
port: 3000
storage:
dataDir: "/tmp/probes_data"
# logging:
# level: "info"
# console:
# level: "info"
# file:
# level: "info"
# path: "/var/log/dial/dial.log"
# rotation:
# size: "50MB"
# frequency: "daily"
# maxFiles: 14
runtime:
maxConcurrentChecks: 20
# logging:
# level: "info"
# console:
# level: "info"
# file:
# level: "info"
# path: "/var/log/dial/dial.log"
# rotation:
# size: "50MB"
# frequency: "daily"
# maxFiles: 14
probes:
execution:
maxConcurrentChecks: 20
defaults:
interval: "30s"

View File

@@ -4,13 +4,14 @@ import { dirname, resolve } from "node:path";
import type { ConfigValidationIssue } from "./schema/issues";
import type {
DefaultsConfig,
EngineRuntimeConfig,
ExecutionConfig,
LoggingConfig,
LogLevel,
RawTargetConfig,
ResolvedLoggingConfig,
ResolvedTargetBase,
RotationFrequency,
ServerStorageConfig,
} from "./types";
import { checkerRegistry } from "./runner";
@@ -87,20 +88,23 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
const configDir = dirname(resolve(configPath));
const server = validated.server ?? {};
const runtime = validated.runtime ?? {};
const listen = server.listen ?? {};
const storage = server.storage ?? {};
const defaults = validated.defaults ?? {};
const host = server.host ?? DEFAULT_HOST;
const port = server.port ?? DEFAULT_PORT;
const dataDir = resolve(configDir, server.dataDir ?? DEFAULT_DATA_DIR);
const host = listen.host ?? DEFAULT_HOST;
const port = listen.port ?? DEFAULT_PORT;
const dataDir = resolve(configDir, storage.dataDir ?? DEFAULT_DATA_DIR);
const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime);
const retentionMs = resolveRetention(runtime);
const probes = validated.probes ?? {};
const execution = probes.execution ?? {};
const maxConcurrentChecks = resolveMaxConcurrentChecks(execution);
const retentionMs = resolveRetention(storage);
const logging = resolveLogging(validated.logging ?? {}, dataDir, configDir);
const logging = resolveLogging(server.logging ?? {}, dataDir, configDir);
const allRuntimeIssues = [...allIssues];
validateLoggingConfig(validated.logging, allRuntimeIssues);
validateLoggingConfig(server.logging, allRuntimeIssues);
if (allRuntimeIssues.length > 0) {
throwConfigIssues(dedupeIssues(allRuntimeIssues));
}
@@ -172,19 +176,19 @@ function resolveLogLevel(level: unknown, fallback: LogLevel): LogLevel {
return fallback;
}
function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
function resolveMaxConcurrentChecks(execution: ExecutionConfig): number {
if (execution.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if (
!isNumber(runtime.maxConcurrentChecks) ||
!Number.isInteger(runtime.maxConcurrentChecks) ||
runtime.maxConcurrentChecks <= 0
!isNumber(execution.maxConcurrentChecks) ||
!Number.isInteger(execution.maxConcurrentChecks) ||
execution.maxConcurrentChecks <= 0
)
return DEFAULT_MAX_CONCURRENT_CHECKS;
return runtime.maxConcurrentChecks;
return execution.maxConcurrentChecks;
}
function resolveRetention(runtime: EngineRuntimeConfig): number {
return parseDuration(runtime.retention ?? DEFAULT_RETENTION);
function resolveRetention(storage: ServerStorageConfig): number {
return parseDuration(storage.retention ?? DEFAULT_RETENTION);
}
function resolveTarget(
@@ -277,8 +281,8 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
validateDurationValue(config.defaults?.interval, "defaults.interval", issues);
validateDurationValue(config.defaults?.timeout, "defaults.timeout", issues);
validateDurationValue(
isString(config.runtime?.retention) ? config.runtime.retention : undefined,
"runtime.retention",
isString(config.server?.storage?.retention) ? config.server.storage.retention : undefined,
"server.storage.retention",
issues,
);
for (let i = 0; i < config.targets.length; i++) {
@@ -328,7 +332,11 @@ function validateLoggingConfig(logging: LoggingConfig | undefined, issues: Confi
if (logging.level !== undefined && !VALID_LOG_LEVELS.includes(logging.level)) {
issues.push(
issue("invalid-value", "logging.level", `日志等级非法: "${logging.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`),
issue(
"invalid-value",
"server.logging.level",
`日志等级非法: "${logging.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
),
);
}
@@ -336,7 +344,7 @@ function validateLoggingConfig(logging: LoggingConfig | undefined, issues: Confi
issues.push(
issue(
"invalid-value",
"logging.console.level",
"server.logging.console.level",
`日志等级非法: "${logging.console.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
),
);
@@ -346,7 +354,7 @@ function validateLoggingConfig(logging: LoggingConfig | undefined, issues: Confi
issues.push(
issue(
"invalid-value",
"logging.file.level",
"server.logging.file.level",
`日志等级非法: "${logging.file.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
),
);
@@ -354,7 +362,7 @@ function validateLoggingConfig(logging: LoggingConfig | undefined, issues: Confi
if (logging.file?.path !== undefined) {
if (!isString(logging.file.path) || logging.file.path.trim() === "") {
issues.push(issue("invalid-value", "logging.file.path", "日志路径不能为空字符串或空白字符串"));
issues.push(issue("invalid-value", "server.logging.file.path", "日志路径不能为空字符串或空白字符串"));
}
}
@@ -363,11 +371,15 @@ function validateLoggingConfig(logging: LoggingConfig | undefined, issues: Confi
try {
const bytes = parseSize(rotation.size);
if (bytes <= 0) {
issues.push(issue("invalid-value", "logging.file.rotation.size", "滚动大小必须为正整数字节数"));
issues.push(issue("invalid-value", "server.logging.file.rotation.size", "滚动大小必须为正整数字节数"));
}
} catch (error) {
issues.push(
issue("invalid-value", "logging.file.rotation.size", error instanceof Error ? error.message : "size 格式非法"),
issue(
"invalid-value",
"server.logging.file.rotation.size",
error instanceof Error ? error.message : "size 格式非法",
),
);
}
}
@@ -376,7 +388,7 @@ function validateLoggingConfig(logging: LoggingConfig | undefined, issues: Confi
issues.push(
issue(
"invalid-value",
"logging.file.rotation.frequency",
"server.logging.file.rotation.frequency",
`滚动频率非法: "${rotation.frequency}",支持: ${VALID_ROTATION_FREQUENCIES.join(", ")}`,
),
);
@@ -384,7 +396,7 @@ function validateLoggingConfig(logging: LoggingConfig | undefined, issues: Confi
if (rotation?.maxFiles !== undefined) {
if (!isNumber(rotation.maxFiles) || !Number.isInteger(rotation.maxFiles) || rotation.maxFiles <= 0) {
issues.push(issue("invalid-value", "logging.file.rotation.maxFiles", "maxFiles 必须为正整数"));
issues.push(issue("invalid-value", "server.logging.file.rotation.maxFiles", "maxFiles 必须为正整数"));
}
}
}

View File

@@ -35,26 +35,8 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external
return Type.Object(
{
defaults: Type.Optional(createDefaultsSchema(checkers)),
logging: Type.Optional(createLoggingSchema()),
runtime: Type.Optional(
Type.Object(
{
maxConcurrentChecks: Type.Optional(Type.Integer({ minimum: 1 })),
retention: Type.Optional(durationSchema),
},
{ additionalProperties: false },
),
),
server: Type.Optional(
Type.Object(
{
dataDir: Type.Optional(Type.String()),
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Integer({ maximum: 65535, minimum: 0 })),
},
{ additionalProperties: false },
),
),
probes: Type.Optional(createProbesSchema()),
server: Type.Optional(createServerSchema()),
targets: Type.Array(external ? createExternalTargetSchema(checkers) : createBaseTargetSchema(checkers), {
minItems: 1,
}),
@@ -144,3 +126,46 @@ function createLoggingSchema(): TSchema {
{ additionalProperties: false },
);
}
function createProbesSchema(): TSchema {
return Type.Object(
{
execution: Type.Optional(
Type.Object(
{
maxConcurrentChecks: Type.Optional(Type.Integer({ minimum: 1 })),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
);
}
function createServerSchema(): TSchema {
return Type.Object(
{
listen: Type.Optional(
Type.Object(
{
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Integer({ maximum: 65535, minimum: 0 })),
},
{ additionalProperties: false },
),
),
logging: Type.Optional(createLoggingSchema()),
storage: Type.Optional(
Type.Object(
{
dataDir: Type.Optional(Type.String()),
retention: Type.Optional(durationSchema),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
);
}

View File

@@ -10,9 +10,8 @@ export interface DefaultsConfig {
timeout?: string;
}
export interface EngineRuntimeConfig {
export interface ExecutionConfig {
maxConcurrentChecks?: number;
retention?: string;
}
export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue };
@@ -43,13 +42,16 @@ export type LogLevel = "debug" | "error" | "fatal" | "info" | "trace" | "warn";
export interface ProbeConfig {
defaults?: DefaultsConfig;
logging?: LoggingConfig;
runtime?: EngineRuntimeConfig;
probes?: ProbesConfig;
server?: ServerConfig;
targets: RawTargetConfig[];
variables?: Record<string, VariableValue>;
}
export interface ProbesConfig {
execution?: ExecutionConfig;
}
export interface RawTargetConfig {
[configKey: string]: unknown;
description?: null | string;
@@ -88,11 +90,21 @@ export interface ResolvedTargetBase {
export type RotationFrequency = "daily" | "hourly" | "weekly";
export interface ServerConfig {
dataDir?: string;
listen?: ServerListenConfig;
logging?: LoggingConfig;
storage?: ServerStorageConfig;
}
export interface ServerListenConfig {
host?: string;
port?: number;
}
export interface ServerStorageConfig {
dataDir?: string;
retention?: string;
}
export interface StoredCheckResult {
duration_ms: null | number;
failure: null | string;

View File

@@ -182,11 +182,14 @@ describe("loadConfig", () => {
await writeFile(
configPath,
`server:
host: "0.0.0.0"
port: 8080
dataDir: "./my-data"
runtime:
maxConcurrentChecks: 5
listen:
host: "0.0.0.0"
port: 8080
storage:
dataDir: "./my-data"
probes:
execution:
maxConcurrentChecks: 5
defaults:
interval: "15s"
timeout: "5s"
@@ -508,7 +511,8 @@ targets:
await writeFile(
configPath,
`server:
dataDir: ${JSON.stringify(dataDir)}
storage:
dataDir: ${JSON.stringify(dataDir)}
targets:
- name: "test"
id: "test"
@@ -760,7 +764,8 @@ targets:
await writeFile(
configPath,
`server:
port: 99999
listen:
port: 99999
targets:
- name: "t"
id: "t"
@@ -769,15 +774,16 @@ targets:
url: "http://a.com"
`,
);
await expectConfigLoadError(configPath, "server.port 数值范围不合法");
await expectConfigLoadError(configPath, "server.listen.port 数值范围不合法");
});
test("非法 maxConcurrentChecks 抛出错误", async () => {
const configPath = join(tempDir, "bad-concurrency.yaml");
await writeFile(
configPath,
`runtime:
maxConcurrentChecks: -1
`probes:
execution:
maxConcurrentChecks: -1
targets:
- name: "t"
id: "t"
@@ -786,7 +792,7 @@ targets:
url: "http://a.com"
`,
);
await expectConfigLoadError(configPath, "runtime.maxConcurrentChecks 数值范围不合法");
await expectConfigLoadError(configPath, "probes.execution.maxConcurrentChecks 数值范围不合法");
});
test("非法 size 格式抛出错误", async () => {
@@ -1621,8 +1627,9 @@ targets:
const configPath = join(tempDir, "retention-custom.yaml");
await writeFile(
configPath,
`runtime:
retention: "24h"
`server:
storage:
retention: "24h"
targets:
- name: "test"
id: "test"
@@ -1638,8 +1645,9 @@ targets:
test("retention 非法格式抛出错误", async () => {
await expectConfigError(
"bad-retention.yaml",
`runtime:
retention: "7x"
`server:
storage:
retention: "7x"
targets:
- name: "test"
id: "test"
@@ -2113,8 +2121,9 @@ targets:
const configPath = join(tempDir, "logging-global-level.yaml");
await writeFile(
configPath,
`logging:
level: "debug"
`server:
logging:
level: "debug"
targets:
- id: "t"
type: http
@@ -2131,10 +2140,11 @@ targets:
const configPath = join(tempDir, "logging-console-level.yaml");
await writeFile(
configPath,
`logging:
level: "warn"
console:
level: "trace"
`server:
logging:
level: "warn"
console:
level: "trace"
targets:
- id: "t"
type: http
@@ -2151,10 +2161,11 @@ targets:
const configPath = join(tempDir, "logging-file-level.yaml");
await writeFile(
configPath,
`logging:
level: "info"
file:
level: "error"
`server:
logging:
level: "info"
file:
level: "error"
targets:
- id: "t"
type: http
@@ -2171,9 +2182,10 @@ targets:
const configPath = join(tempDir, "logging-abs-path.yaml");
await writeFile(
configPath,
`logging:
file:
path: "/var/log/dial/app.log"
`server:
logging:
file:
path: "/var/log/dial/app.log"
targets:
- id: "t"
type: http
@@ -2189,9 +2201,10 @@ targets:
const configPath = join(tempDir, "logging-rel-path.yaml");
await writeFile(
configPath,
`logging:
file:
path: "custom-logs/app.log"
`server:
logging:
file:
path: "custom-logs/app.log"
targets:
- id: "t"
type: http
@@ -2207,12 +2220,13 @@ targets:
const configPath = join(tempDir, "logging-rotation.yaml");
await writeFile(
configPath,
`logging:
file:
rotation:
size: "100MB"
frequency: "hourly"
maxFiles: 30
`server:
logging:
file:
rotation:
size: "100MB"
frequency: "hourly"
maxFiles: 30
targets:
- id: "t"
type: http
@@ -2230,57 +2244,61 @@ targets:
test("logging.level 非法等级抛出错误", async () => {
await expectConfigError(
"logging-bad-level.yaml",
`logging:
level: "verbose"
`server:
logging:
level: "verbose"
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"logging.level",
"server.logging.level",
);
});
test("logging.console.level 非法等级抛出错误", async () => {
await expectConfigError(
"logging-bad-console-level.yaml",
`logging:
console:
level: "nope"
`server:
logging:
console:
level: "nope"
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"logging.console.level",
"server.logging.console.level",
);
});
test("logging.file.level 非法等级抛出错误", async () => {
await expectConfigError(
"logging-bad-file-level.yaml",
`logging:
file:
level: 123
`server:
logging:
file:
level: 123
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"logging.file.level",
"server.logging.file.level",
);
});
test("logging.file.rotation.size 非法格式抛出错误", async () => {
await expectConfigError(
"logging-bad-rotation-size.yaml",
`logging:
file:
rotation:
size: "100TB"
`server:
logging:
file:
rotation:
size: "100TB"
targets:
- id: "t"
type: http
@@ -2294,50 +2312,130 @@ targets:
test("logging.file.rotation.frequency 非法值抛出错误", async () => {
await expectConfigError(
"logging-bad-frequency.yaml",
`logging:
file:
rotation:
frequency: "monthly"
`server:
logging:
file:
rotation:
frequency: "monthly"
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"logging.file.rotation.frequency",
"server.logging.file.rotation.frequency",
);
});
test("logging.file.rotation.maxFiles 非整数抛出错误", async () => {
await expectConfigError(
"logging-bad-maxfiles.yaml",
`logging:
file:
rotation:
maxFiles: 3.5
`server:
logging:
file:
rotation:
maxFiles: 3.5
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"logging.file.rotation.maxFiles",
"server.logging.file.rotation.maxFiles",
);
});
test("logging.file.path 空字符串抛出错误", async () => {
await expectConfigError(
"logging-empty-path.yaml",
`logging:
file:
path: ""
`server:
logging:
file:
path: ""
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"logging.file.path",
"server.logging.file.path",
);
});
});
describe("旧路径拒绝", () => {
test("顶层 runtime 段应被拒绝为未知字段", async () => {
await expectConfigError(
"legacy-runtime.yaml",
`runtime:
maxConcurrentChecks: 10
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"runtime 是未知字段",
);
});
test("顶层 logging 段应被拒绝为未知字段", async () => {
await expectConfigError(
"legacy-logging.yaml",
`logging:
level: "info"
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"logging 是未知字段",
);
});
test("server.host 应被拒绝为未知字段", async () => {
await expectConfigError(
"legacy-server-host.yaml",
`server:
host: "0.0.0.0"
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"host 是未知字段",
);
});
test("server.port 应被拒绝为未知字段", async () => {
await expectConfigError(
"legacy-server-port.yaml",
`server:
port: 8080
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"port 是未知字段",
);
});
test("server.dataDir 应被拒绝为未知字段", async () => {
await expectConfigError(
"legacy-server-datadir.yaml",
`server:
dataDir: "/tmp/data"
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"dataDir 是未知字段",
);
});
});

View File

@@ -220,9 +220,9 @@ describe("config variables", () => {
expect(typeof http2["ignoreSSL"]).toBe("boolean");
});
test("runtime 段不替换", () => {
test("server.storage 段不替换", () => {
const result = resolveVariables({
runtime: { maxConcurrentChecks: 10, retention: "${retention}" },
server: { storage: { retention: "${retention}" } },
targets: [
{
http: { url: "${host}" },
@@ -234,8 +234,44 @@ describe("config variables", () => {
});
expect(result.issues).toHaveLength(0);
const config = result.config as { runtime: { retention: string } };
expect(config.runtime.retention).toBe("${retention}");
const config = result.config as { server: { storage: { retention: string } } };
expect(config.server.storage.retention).toBe("${retention}");
});
test("probes 段不替换", () => {
const result = resolveVariables({
probes: { execution: { maxConcurrentChecks: "${maxConcurrentChecks}" } },
targets: [
{
http: { url: "${host}" },
id: "probes-no-replace",
type: "http",
},
],
variables: { host: "https://example.com", maxConcurrentChecks: "20" },
});
expect(result.issues).toHaveLength(0);
const config = result.config as { probes: { execution: { maxConcurrentChecks: string } } };
expect(config.probes.execution.maxConcurrentChecks).toBe("${maxConcurrentChecks}");
});
test("server.logging 段不替换", () => {
const result = resolveVariables({
server: { logging: { level: "${logLevel}" } },
targets: [
{
http: { url: "${host}" },
id: "logging-no-replace",
type: "http",
},
],
variables: { host: "https://example.com", logLevel: "debug" },
});
expect(result.issues).toHaveLength(0);
const config = result.config as { server: { logging: { level: string } } };
expect(config.server.logging.level).toBe("${logLevel}");
});
test("variables 段为非对象时报错", () => {