1
0

refactor: 移除顶层 defaults 配置段,简化为 target 显式字段 > 代码内置默认值

- 移除 DefaultsConfig 类型、ProbeConfig.defaults 字段
- 移除 CheckerSchemas.defaults、ResolveContext.defaults、CheckerValidationInput.defaults
- 更新所有 checker schema/resolve/validate 删除 defaults 合并逻辑
- 更新 config-loader 不再读取传递 defaults
- 更新测试、README、DEVELOPMENT、probes.example.yaml
- 重新生成 probe-config.schema.json(不含 defaults)
- 同步 delta specs 到主规范
- 归档 openspec change
This commit is contained in:
2026-05-21 16:53:12 +08:00
parent e448cb4654
commit 79358ba50d
52 changed files with 196 additions and 940 deletions

View File

@@ -64,7 +64,7 @@ src/
metrics.ts GET /api/targets/:id/metrics metrics.ts GET /api/targets/:id/metrics
history.ts GET /api/targets/:id/history history.ts GET /api/targets/:id/history
checker/ checker/
types.ts 基础类型定义ResolvedTargetBase、RawTargetConfig、DefaultsConfig、CheckResult 等基础 interface types.ts 基础类型定义ResolvedTargetBase、RawTargetConfig、CheckResult 等基础 interface
config-loader.ts YAML 配置解析、契约校验、语义校验与运行期解析(输出 ResolvedConfig config-loader.ts YAML 配置解析、契约校验、语义校验与运行期解析(输出 ResolvedConfig
variables.ts 配置 variables 提取、target 字符串变量替换和 unresolved-variable issue 生成 variables.ts 配置 variables 提取、target 字符串变量替换和 unresolved-variable issue 生成
schema/ TypeBox + Ajv 配置契约、schema fragments、issue 渲染和 schema 导出入口 schema/ TypeBox + Ajv 配置契约、schema fragments、issue 渲染和 schema 导出入口
@@ -237,7 +237,7 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode:
- **后端内部扩展**`checker/types.ts``CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetId` 等内部字段 - **后端内部扩展**`checker/types.ts``CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetId` 等内部字段
- 存储层类型(`StoredTarget``StoredCheckResult`)独立定义,与 API 类型分离 - 存储层类型(`StoredTarget``StoredCheckResult`)独立定义,与 API 类型分离
- **Checker 类型分层** - **Checker 类型分层**
- `checker/types.ts` 定义 base interface`ResolvedTargetBase``RawTargetConfig``DefaultsConfig`),使用 index signature 支持扩展 - `checker/types.ts` 定义 base interface`ResolvedTargetBase``RawTargetConfig`),使用 index signature 支持扩展
- 各 checker 在自己的 `types.ts` 中定义具体类型(如 `ResolvedHttpTarget``ResolvedCommandTarget`),满足 base interface 约束 - 各 checker 在自己的 `types.ts` 中定义具体类型(如 `ResolvedHttpTarget``ResolvedCommandTarget`),满足 base interface 约束
- 中间层engine、store、config-loader只依赖 base interface不感知具体 checker 类型 - 中间层engine、store、config-loader只依赖 base interface不感知具体 checker 类型
- `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>` 使用泛型约束 `resolve` 返回值以及 `execute``serialize` 的 target 参数 - `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>` 使用泛型约束 `resolve` 返回值以及 `execute``serialize` 的 target 参数
@@ -267,9 +267,9 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode:
契约层使用 `src/server/checker/schema/` 中的 TypeBox fragments 生成 JSON Schema并用 Ajv 执行启动期校验。Ajv 必须保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。 契约层使用 `src/server/checker/schema/` 中的 TypeBox fragments 生成 JSON Schema并用 Ajv 执行启动期校验。Ajv 必须保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。
默认对象策略是 `additionalProperties: false`。只有明确声明的动态键值表可以开放任意键名,例如 `variables``http.headers``defaults.http.headers``expect.headers``cmd.env` 默认对象策略是 `additionalProperties: false`。只有明确声明的动态键值表可以开放任意键名,例如 `variables``http.headers``expect.headers``cmd.env`
契约校验和语义 validator 都必须返回 `ConfigValidationIssue[]`,不要在 validator 内直接拼接最终用户错误字符串。最终错误由 `formatConfigIssues()` 统一渲染,错误路径需要尽量包含 `targetName``defaults`/root 路径。 契约校验和语义 validator 都必须返回 `ConfigValidationIssue[]`,不要在 validator 内直接拼接最终用户错误字符串。最终错误由 `formatConfigIssues()` 统一渲染,错误路径需要尽量包含 `targetName` 或 root 路径。
新增或修改配置字段时必须同步更新TypeBox schema fragments、`probe-config.schema.json` 导出、对应语义 validator、单元测试和 README/DEVELOPMENT 用户文档。提交前运行 `bun run schema:check` 确认导出 schema 与 fragments 一致。 新增或修改配置字段时必须同步更新TypeBox schema fragments、`probe-config.schema.json` 导出、对应语义 validator、单元测试和 README/DEVELOPMENT 用户文档。提交前运行 `bun run schema:check` 确认导出 schema 与 fragments 一致。
@@ -303,7 +303,7 @@ checkerRegistry单例
| ------------- | ------------------------------------------------------------------------------------------ | | ------------- | ------------------------------------------------------------------------------------------ |
| `index.ts` | 模块入口re-export Checker 类 | | `index.ts` | 模块入口re-export Checker 类 |
| `types.ts` | Checker 专属类型RawXxxTargetConfig、Raw/Resolved XxxExpectConfig、ResolvedXxxTarget 等) | | `types.ts` | Checker 专属类型RawXxxTargetConfig、Raw/Resolved XxxExpectConfig、ResolvedXxxTarget 等) |
| `schema.ts` | TypeBox 契约 schemaconfig / defaults / expect 部分) | | `schema.ts` | TypeBox 契约 schemaconfig / expect 部分) |
| `validate.ts` | 启动期语义校验JSON Schema 无法表达的规则) | | `validate.ts` | 启动期语义校验JSON Schema 无法表达的规则) |
| `execute.ts` | Checker 类resolve默认值合并 + 解析、execute执行检查、serializeDB 持久化) | | `execute.ts` | Checker 类resolve默认值合并 + 解析、execute执行检查、serializeDB 持久化) |
| `expect.ts` | Checker 专用断言函数 | | `expect.ts` | Checker 专用断言函数 |
@@ -315,14 +315,13 @@ checkerRegistry单例
- `RawXxxTargetConfig` — YAML 原始配置类型 - `RawXxxTargetConfig` — YAML 原始配置类型
- `RawXxxExpectConfig` / `ResolvedXxxExpectConfig` — Raw expect 字段类型与运行期 Resolved expect 执行计划类型 - `RawXxxExpectConfig` / `ResolvedXxxExpectConfig` — Raw expect 字段类型与运行期 Resolved expect 执行计划类型
- `XxxDefaultsConfig` — defaults 专属字段类型
- `ResolvedXxxTarget extends ResolvedTargetBase` — resolve 后的完整类型,含 `type: "xxx"` 字面量 - `ResolvedXxxTarget extends ResolvedTargetBase` — resolve 后的完整类型,含 `type: "xxx"` 字面量
**注意**:不需要修改顶层 `checker/types.ts`。base interface 使用 index signature`[key: string]: unknown`checker 专属类型通过 `extends ResolvedTargetBase` 自动兼容。 **注意**:不需要修改顶层 `checker/types.ts`。base interface 使用 index signature`[key: string]: unknown`checker 专属类型通过 `extends ResolvedTargetBase` 自动兼容。
#### 1.7.3 步骤二:创建 TypeBox 契约 Schema #### 1.7.3 步骤二:创建 TypeBox 契约 Schema
`src/server/checker/runner/tcp/schema.ts` 中定义 `CheckerSchemas`config / defaults / expect 部分)。参考 `http/schema.ts``cmd/schema.ts`,使用 `schema/fragments.ts` 中的共享片段。 `src/server/checker/runner/tcp/schema.ts` 中定义 `CheckerSchemas`config / expect 部分)。参考 `http/schema.ts``cmd/schema.ts`,使用 `schema/fragments.ts` 中的共享片段。
**可复用的共享 fragments**(来自 `schema/fragments.ts` **可复用的共享 fragments**(来自 `schema/fragments.ts`
@@ -368,17 +367,16 @@ TcpChecker implements Checker
readonly schemas ← tcpCheckerSchemas readonly schemas ← tcpCheckerSchemas
validate(input) ← 调用 validateTcpConfig(input) validate(input) ← 调用 validateTcpConfig(input)
resolve(target, ctx)← 默认值合并 + 解析,返回 satisfies ResolvedTcpTarget resolve(target, ctx)← 内置默认值填充 + 解析,返回 satisfies ResolvedTcpTarget
execute(target, ctx)← 执行检查,返回 CheckResult execute(target, ctx)← 执行检查,返回 CheckResult
serialize(target) ← 返回 { config, target } 用于 DB 持久化 serialize(target) ← 返回 { config, target } 用于 DB 持久化
``` ```
**`resolve()` 规范** **`resolve()` 规范**
- 只做默认值合并、路径解析、单位转换,**不执行校验** - 只做内置默认值填充、路径解析、单位转换,**不执行校验**
- 若 checker 支持 expect必须保留 `rawExpect` 为变量替换后的 Raw 配置快照,并生成只供 `execute()` 使用的 Resolved `expect` - 若 checker 支持 expect必须保留 `rawExpect` 为变量替换后的 Raw 配置快照,并生成只供 `execute()` 使用的 Resolved `expect`
- 返回 `satisfies ResolvedXxxTarget` 确保类型正确 - 返回 `satisfies ResolvedXxxTarget` 确保类型正确
- 通过 `context.defaults[this.configKey]` 访问 checker 专属默认值(需 `as` 断言为具体类型)
**expect 五层管线**:每种断言模型从定义到执行经过五个阶段,每层使用对应的共享函数: **expect 五层管线**:每种断言模型从定义到执行经过五个阶段,每层使用对应的共享函数:
@@ -457,13 +455,13 @@ if (r.body) {
注册后,以下管线会通过 registry 自动委托,**无需新增 type 分支** 注册后,以下管线会通过 registry 自动委托,**无需新增 type 分支**
| 模块 | 自动行为 | | 模块 | 自动行为 |
| -------------------- | ------------------------------------------------------------------------ | | -------------------- | -------------------------------------------------------------- |
| `schema/builder.ts` | 遍历 registry 生成全量 JSON Schemadefaults.tcp + target.tcp + expect | | `schema/builder.ts` | 遍历 registry 生成全量 JSON Schematarget.tcp + expect |
| `schema/validate.ts` | 按注册 checker 构建 Ajv 校验,自动识别 `type: tcp` | | `schema/validate.ts` | 按注册 checker 构建 Ajv 校验,自动识别 `type: tcp` |
| `config-loader.ts` | 遍历 registry 调用每个 checker 的 `validate()` + `resolve()` | | `config-loader.ts` | 遍历 registry 调用每个 checker 的 `validate()` + `resolve()` |
| `engine.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `execute()` | | `engine.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `execute()` |
| `store.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `serialize()` | | `store.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `serialize()` |
注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新配置示例、文档和测试。 注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新配置示例、文档和测试。

102
README.md
View File

@@ -135,13 +135,8 @@ probes: # 拨测运行时配置(可省略)
variables: # 配置变量(可省略) variables: # 配置变量(可省略)
env_name: "生产" env_name: "生产"
base_url: "https://api.example.com" base_url: "https://api.example.com"
default_interval: "30s" # 通过变量在多个 target 间共享常用值
defaults: # 全局默认值(均可省略) default_timeout: "10s"
interval: "30s"
timeout: "10s"
# http: ...
# cmd: ...
# llm: ...
targets: # 拨测目标列表(必填) targets: # 拨测目标列表(必填)
- id: "baidu-home" - id: "baidu-home"
@@ -197,14 +192,15 @@ targets: # 拨测目标列表(必填)
控制台始终输出pretty 格式),文件始终输出 JSONL 格式并支持滚动。`rotation.size``rotation.frequency` 任一条件触发即滚动。 控制台始终输出pretty 格式),文件始终输出 JSONL 格式并支持滚动。`rotation.size``rotation.frequency` 任一条件触发即滚动。
### defaults — 全局默认值 ### 内置默认值
| 字段 | 说明 | 必填 | 默认值 | 未显式配置时,系统使用以下内置默认值
| ---------- | -------- | ---- | ------ |
| `interval` | 拨测间隔 | 否 | `30s` |
| `timeout` | 超时时间 | 否 | `10s` |
各 checker 专属的默认配置见对应章节。 - `interval``30s`(拨测间隔)
- `timeout``10s`(超时时间)
- 各 checker 专属默认值见对应章节
如需在多个 target 间共享相同的配置值,可使用 `variables` 定义变量,然后在 target 中通过 `${var}` 引用。例如在 `variables` 中定义 `default_interval: "30s"`,在多个 target 的 `interval` 字段写 `${default_interval}`
### variables — 配置变量 ### variables — 配置变量
@@ -227,30 +223,23 @@ targets: # 拨测目标列表(必填)
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null允许空字符串 | 否 | | | `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null允许空字符串 | 否 | |
| `type` | 目标类型:`http``cmd``db``tcp``udp``icmp``llm` | 是 | | | `type` | 目标类型:`http``cmd``db``tcp``udp``icmp``llm` | 是 | |
| `group` | 分组名称 | 否 | `default` | | `group` | 分组名称 | 否 | `default` |
| `interval` | 覆盖全局拨测间隔 | 否 | | | `interval` | 拨测间隔,未配置时使用内置默认值 `30s` | 否 | `30s` |
| `timeout` | 覆盖全局超时时间 | 否 | | | `timeout` | 超时时间,未配置时使用内置默认值 `10s` | 否 | `10s` |
--- ---
### HTTP Checker`type: http` ### HTTP Checker`type: http`
**全局默认值(`defaults.http`**
| 字段 | 说明 | 必填 | 默认值 |
| -------------- | -------------------------------------------------- | ---- | ------- |
| `maxBodyBytes` | 响应体最大字节数 | 否 | `100MB` |
| `headers` | 默认请求头target 中的 headers 会合并覆盖同名头) | 否 | |
**配置项** **配置项**
| 字段 | 说明 | 必填 | 默认值 | | 字段 | 说明 | 必填 | 默认值 |
| ------------------- | --------------------------------------- | ---- | ------- | | ------------------- | ------------------- | ---- | ------- |
| `http.url` | 目标 URL | 是 | | | `http.url` | 目标 URL | 是 | |
| `http.method` | HTTP 方法 | 否 | `GET` | | `http.method` | HTTP 方法 | 否 | `GET` |
| `http.headers` | 请求头(与 defaults.http.headers 合并) | 否 | | | `http.headers` | 请求头 | 否 | |
| `http.body` | 请求体 | 否 | | | `http.body` | 请求体 | 否 | |
| `http.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` | | `http.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` |
| `http.maxRedirects` | 最大重定向跟随次数 | 否 | `0` | | `http.maxRedirects` | 最大重定向跟随次数 | 否 | `0` |
**expect 校验项** **expect 校验项**
@@ -288,13 +277,6 @@ targets: # 拨测目标列表(必填)
### Cmd Checker`type: cmd` ### Cmd Checker`type: cmd`
**全局默认值(`defaults.cmd`**
| 字段 | 说明 | 必填 | 默认值 |
| ---------------- | -------------------------------------- | ---- | ------- |
| `maxOutputBytes` | 输出最大字节数 | 否 | `100MB` |
| `cwd` | 默认工作目录(相对于配置文件所在目录) | 否 | `.` |
**配置项** **配置项**
| 字段 | 说明 | 必填 | 默认值 | | 字段 | 说明 | 必填 | 默认值 |
@@ -405,14 +387,6 @@ targets: # 拨测目标列表(必填)
### UDP Checker`type: udp` ### UDP Checker`type: udp`
**全局默认值(`defaults.udp`**
| 字段 | 说明 | 必填 | 默认值 |
| ------------------ | ---------------------------------------- | ---- | ------ |
| `encoding` | payload 编码 | 否 | `text` |
| `responseEncoding` | 响应解码 | 否 | `text` |
| `maxResponseBytes` | 响应最大字节数,支持 `KB`/`MB`/`GB` 单位 | 否 | `4KB` |
**配置项** **配置项**
| 字段 | 说明 | 必填 | 默认值 | | 字段 | 说明 | 必填 | 默认值 |
@@ -503,33 +477,21 @@ ICMP checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS
### LLM Checker`type: llm` ### LLM Checker`type: llm`
**全局默认值(`defaults.llm`**
| 字段 | 说明 | 必填 | 默认值 |
| ----------------- | ------------------- | ---- | ------- |
| `mode` | 调用模式 | 否 | |
| `headers` | 默认请求头 | 否 | |
| `ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` |
| `options` | 生成选项 | 否 | |
| `providerOptions` | Provider 专属选项 | 否 | |
不支持 `provider``url``model``key``authToken``prompt`
**配置项** **配置项**
| 字段 | 说明 | 必填 | 默认值 | | 字段 | 说明 | 必填 | 默认值 |
| --------------------- | ----------------------------------------------------------- | ---- | ------- | | --------------------- | ------------------------------------------------------ | ---- | ------- |
| `llm.provider` | 模型提供方:`openai``openai-responses``anthropic` | 是 | | | `llm.provider` | 模型提供方:`openai``openai-responses``anthropic` | 是 | |
| `llm.url` | API base URL | 是 | | | `llm.url` | API base URL | 是 | |
| `llm.model` | 模型名称 | 是 | | | `llm.model` | 模型名称 | 是 | |
| `llm.prompt` | 单轮 prompt | 是 | | | `llm.prompt` | 单轮 prompt | 是 | |
| `llm.mode` | 调用模式:`http`(非流式)或 `stream`(流式) | 否 | `http` | | `llm.mode` | 调用模式:`http`(非流式)或 `stream`(流式) | 否 | `http` |
| `llm.key` | API key支持 `${VAR}` 变量替换 | 否 | `""` | | `llm.key` | API key支持 `${VAR}` 变量替换 | 否 | `""` |
| `llm.authToken` | Bearer token`anthropic` provider`key` 互斥) | 否 | | | `llm.authToken` | Bearer token`anthropic` provider`key` 互斥) | 否 | |
| `llm.headers` | 附加请求头(与 `defaults.llm.headers` 合并) | 否 | | | `llm.headers` | 附加请求头 | 否 | |
| `llm.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` | | `llm.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` |
| `llm.options` | 生成选项(与 `defaults.llm.options` 合并) | 否 | | | `llm.options` | 生成选项 | 否 | |
| `llm.providerOptions` | Provider 专属选项(与 `defaults.llm.providerOptions` 合并) | 否 | | | `llm.providerOptions` | Provider 专属选项 | 否 | |
`llm.options` 支持 `maxOutputTokens`(默认 `16`)、`temperature`(默认 `0`)、`topP``topK``presencePenalty``frequencyPenalty``stopSequences``seed` `llm.options` 支持 `maxOutputTokens`(默认 `16`)、`temperature`(默认 `0`)、`topP``topK``presencePenalty``frequencyPenalty``stopSequences``seed`

View File

@@ -6,5 +6,16 @@
"type": "local", "type": "local",
"command": ["bunx", "tdesign-mcp-server@latest"] "command": ["bunx", "tdesign-mcp-server@latest"]
} }
},
"permission": {
"bash": {
"npm *": "deny",
"npx *": "deny",
"pnpm *": "deny",
"pnpx *": "deny"
},
"external_directory": {
"/tmp/**": "allow"
}
} }
} }

View File

@@ -31,7 +31,7 @@
- **THEN** 这些类型 SHALL 全部定义在该 checker 目录的 `types.ts` 中,不在顶层 `types.ts` - **THEN** 这些类型 SHALL 全部定义在该 checker 目录的 `types.ts` 中,不在顶层 `types.ts`
#### Scenario: schema.ts 包含 TypeBox schema 定义 #### Scenario: schema.ts 包含 TypeBox schema 定义
- **WHEN** 开发者需要该 checker 的 config/defaults/expect schema - **WHEN** 开发者需要该 checker 的 configexpect schema
- **THEN** 这些 schema SHALL 定义在该 checker 目录的 `schema.ts` - **THEN** 这些 schema SHALL 定义在该 checker 目录的 `schema.ts`
#### Scenario: execute.ts 包含 Checker 类实现 #### Scenario: execute.ts 包含 Checker 类实现

View File

@@ -5,15 +5,15 @@
## Requirements ## Requirements
### Requirement: Checker 配置契约片段 ### Requirement: Checker 配置契约片段
系统 SHALL 支持 checker 提供自身 TypeBox 配置契约片段,用于描述该 checker 的 defaults 分组、target 领域分组和 expect 分组。公共配置加载模块 SHALL 通过 registry 获取已注册 checker 的契约片段,并组合为启动期 Ajv 契约校验流程和外部 `probe-config.schema.json` 导出流程。 系统 SHALL 支持 checker 提供自身 TypeBox 配置契约片段,用于描述该 checker 的 target 领域分组和 expect 分组。公共配置加载模块 SHALL 通过 registry 获取已注册 checker 的契约片段,并组合为启动期 Ajv 契约校验流程和外部 `probe-config.schema.json` 导出流程。
#### Scenario: HTTP checker 提供契约片段 #### Scenario: HTTP checker 提供契约片段
- **WHEN** HTTP checker 被注册 - **WHEN** HTTP checker 被注册
- **THEN** registry SHALL 能提供 HTTP defaults、HTTP target 和 HTTP expect 的 TypeBox 契约片段 - **THEN** registry SHALL 能提供 HTTP target 和 HTTP expect 的 TypeBox 契约片段
#### Scenario: Cmd checker 提供契约片段 #### Scenario: Cmd checker 提供契约片段
- **WHEN** Cmd checker 被注册 - **WHEN** Cmd checker 被注册
- **THEN** registry SHALL 能提供 Cmd defaults、Cmd target 和 Cmd expect 的 TypeBox 契约片段 - **THEN** registry SHALL 能提供 Cmd target 和 Cmd expect 的 TypeBox 契约片段
#### Scenario: 新 checker 只维护自身契约 #### Scenario: 新 checker 只维护自身契约
- **WHEN** 开发者新增一个 checker 类型 - **WHEN** 开发者新增一个 checker 类型

View File

@@ -121,7 +121,7 @@ targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHA
- **THEN** 系统 SHALL 将该字段替换为 string 类型的 "localhost" - **THEN** 系统 SHALL 将该字段替换为 string 类型的 "localhost"
### Requirement: 替换范围限制 ### Requirement: 替换范围限制
变量替换 SHALL 仅作用于 targets 段。`id``type` 字段 MUST NOT 参与变量替换。`server``probes``defaults` 段 MUST NOT 参与变量替换。系统 SHALL 递归遍历 target 对象树中所有字符串值进行替换(包括嵌套对象和数组元素中的字符串)。 变量替换 SHALL 仅作用于 targets 段。`id``type` 字段 MUST NOT 参与变量替换。`server``probes` 段 MUST NOT 参与变量替换。系统 SHALL 递归遍历 target 对象树中所有字符串值进行替换(包括嵌套对象和数组元素中的字符串)。顶层 `defaults` 不再是合法配置段,因此不属于变量替换范围。
#### Scenario: target 嵌套对象中的变量替换 #### Scenario: target 嵌套对象中的变量替换
- **WHEN** target 配置 `http.headers.Authorization: "${token}"` 且 variables 中定义 `token: "Bearer abc"` - **WHEN** target 配置 `http.headers.Authorization: "${token}"` 且 variables 中定义 `token: "Bearer abc"`
@@ -139,10 +139,6 @@ targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHA
- **WHEN** target 配置 `type: "${checker_type}"` 且 variables 中定义 `checker_type: "http"` - **WHEN** target 配置 `type: "${checker_type}"` 且 variables 中定义 `checker_type: "http"`
- **THEN** 系统 SHALL 保持 type 字段值为字面量 `"${checker_type}"`,不进行替换 - **THEN** 系统 SHALL 保持 type 字段值为字面量 `"${checker_type}"`,不进行替换
#### Scenario: defaults 段不替换
- **WHEN** defaults 配置 `interval: "${default_interval}"` 且 variables 中定义 `default_interval: "30s"`
- **THEN** 系统 SHALL 保持 defaults.interval 为字面量 `"${default_interval}"`,不进行替换
#### Scenario: server 段不替换 #### Scenario: server 段不替换
- **WHEN** server 配置 `listen.host: "${server_host}"` 且 variables 中定义 `server_host: "0.0.0.0"` - **WHEN** server 配置 `listen.host: "${server_host}"` 且 variables 中定义 `server_host: "0.0.0.0"`
- **THEN** 系统 SHALL 保持 server.listen.host 为字面量 `"${server_host}"`,不进行替换 - **THEN** 系统 SHALL 保持 server.listen.host 为字面量 `"${server_host}"`,不进行替换
@@ -151,6 +147,10 @@ targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHA
- **WHEN** probes 配置 `execution.maxConcurrentChecks: "${max_checks}"` 且 variables 中定义 `max_checks: 5` - **WHEN** probes 配置 `execution.maxConcurrentChecks: "${max_checks}"` 且 variables 中定义 `max_checks: 5`
- **THEN** 系统 SHALL 保持 probes.execution.maxConcurrentChecks 为字面量 `"${max_checks}"`,不进行替换 - **THEN** 系统 SHALL 保持 probes.execution.maxConcurrentChecks 为字面量 `"${max_checks}"`,不进行替换
#### Scenario: defaults 段被拒绝
- **WHEN** 配置文件声明顶层 `defaults`
- **THEN** 系统 SHALL 在契约校验阶段拒绝该未知字段,而不是尝试变量替换
### Requirement: 变量替换错误报告 ### Requirement: 变量替换错误报告
变量替换阶段的错误 SHALL 作为 `ConfigValidationIssue` 输出code 为 `unresolved-variable`。错误信息 SHALL 包含 target 索引、target id、字段路径和变量名。 变量替换阶段的错误 SHALL 作为 `ConfigValidationIssue` 输出code 为 `unresolved-variable`。错误信息 SHALL 包含 target 索引、target id、字段路径和变量名。

View File

@@ -0,0 +1,32 @@
## Purpose
定义 HTTP 类型拨测目标:通过 `type: http` 配置 HTTP/HTTPS 探测支持请求方法、headers、body、SSL 校验、重定向和响应体大小限制,捕获 HTTP 状态码、响应头、响应体和执行耗时,按 expect 规则校验并生成 matched 判定。
## Requirements
### Requirement: HTTP target 配置
系统 SHALL 支持 `type: http` 的 target 配置,通过 `http.url` 描述目标 HTTP 地址并通过可选字段控制请求方法、请求体、headers、SSL 校验、重定向和响应体大小限制。
#### Scenario: 解析最简 HTTP target
- **WHEN** YAML 中 target 配置 `type: http``http.url: "https://example.com"`
- **THEN** 系统 SHALL 将其解析为 HTTP checker并填充 `method=GET``maxBodyBytes=100MB``ignoreSSL=false``maxRedirects=0`、headers、body、interval、timeout、group 和 expect 配置
#### Scenario: HTTP target 缺少 url
- **WHEN** YAML 中 target 配置 `type: http` 但缺少 `http.url`
- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 http.url 字段
#### Scenario: HTTP target 覆盖请求方法
- **WHEN** YAML 中某个 HTTP target 显式配置 `http.method: POST`
- **THEN** 该 target SHALL 使用自身 method 字段的值,而不是内置默认值 GET
#### Scenario: HTTP target 覆盖响应体大小限制
- **WHEN** YAML 中某个 HTTP target 显式配置 `http.maxBodyBytes`
- **THEN** 该 target SHALL 使用自身 maxBodyBytes 字段的值,而不是内置默认值 100MB
#### Scenario: HTTP target 覆盖 headers
- **WHEN** YAML 中某个 HTTP target 显式配置 `http.headers`
- **THEN** 该 target SHALL 使用自身 headers 字段的值,不再与规则文件级 defaults 合并
#### Scenario: HTTP 序列化展示摘要
- **WHEN** 系统同步 HTTP target 到 targets 表
- **THEN** `target` 展示摘要 SHALL 为 HTTP URL`config` JSON SHALL 包含 resolved 后的 url、method、headers、body、ignoreSSL、maxBodyBytes 和 maxRedirects

View File

@@ -24,25 +24,27 @@
- **THEN** 系统 SHALL 以配置错误退出并提示 `server` 中存在未知字段 `dataDir` - **THEN** 系统 SHALL 以配置错误退出并提示 `server` 中存在未知字段 `dataDir`
### Requirement: YAML 配置文件格式 ### Requirement: YAML 配置文件格式
系统 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`(可选)字段。 系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、probes 执行配置、可选的 variables 段和 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` 不再是合法配置段。
`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: 完整配置文件解析 #### Scenario: 完整配置文件解析
- **WHEN** 系统启动并读取包含 server.listen、server.storage、server.logging、probes.execution、variables、defaults、targets含 id、group 字段)的 YAML 配置文件 - **WHEN** 系统启动并读取包含 server.listen、server.storage、server.logging、probes.execution、variablestargets含 id、group 字段)的 YAML 配置文件
- **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务、调度引擎和对应 checker runner - **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务、调度引擎和对应 checker runner
#### Scenario: defaults 配置段被拒绝
- **WHEN** 配置文件声明顶层 `defaults`
- **THEN** 系统 SHALL 以配置错误退出并提示存在未知字段 `defaults`
#### Scenario: 最简 HTTP 配置文件解析 #### Scenario: 最简 HTTP 配置文件解析
- **WHEN** 系统读取只包含一个 `type: http` target`id``http.url`)的 YAML 配置文件(省略 server、probes、variables、defaults 和 expect - **WHEN** 系统读取只包含一个 `type: http` target`id``http.url`)的 YAML 配置文件(省略 server、probes、variables 和 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 - **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 配置文件解析 #### Scenario: 最简 cmd 配置文件解析
- **WHEN** 系统读取只包含一个 `type: cmd` target`id``cmd.exec`)的 YAML 配置文件 - **WHEN** 系统读取只包含一个 `type: cmd` target`id``cmd.exec`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, cmd.cwd 为配置文件所在目录, cmd.maxOutputBytes=100MB并保留 name=null、description=null - **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, cmd.cwd 为配置文件所在目录, cmd.maxOutputBytes=100MB并保留 name=null、description=null
#### Scenario: per-target 配置覆盖全局默认值 #### Scenario: per-target 配置覆盖内置默认值
- **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段 - **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的可选字段
- **THEN** 该 target SHALL 使用其自身的值,不受 defaults 中对应字段影响 - **THEN** 该 target SHALL 使用其自身的值,不受对应内置默认值影响
#### Scenario: HTTP target 配置 ignoreSSL #### Scenario: HTTP target 配置 ignoreSSL
- **WHEN** YAML 配置中 HTTP target 设置 `http.ignoreSSL: true` - **WHEN** YAML 配置中 HTTP target 设置 `http.ignoreSSL: true`
@@ -58,43 +60,27 @@
#### Scenario: 最简 tcp 配置文件解析 #### Scenario: 最简 tcp 配置文件解析
- **WHEN** 系统读取只包含一个 `type: tcp` target`id``tcp.host``tcp.port`)的 YAML 配置文件 - **WHEN** 系统读取只包含一个 `type: tcp` target`id``tcp.host``tcp.port`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default", tcp.readBanner=false, tcp.bannerReadTimeout=2000, tcp.maxBannerBytes=4096并保留 name=null、description=null - **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group=default, tcp.readBanner=false, tcp.bannerReadTimeout=2000, tcp.maxBannerBytes=4096并保留 name=null、description=null
#### Scenario: defaults.tcp 配置 banner 默认值
- **WHEN** YAML 配置中 defaults.tcp 设置 `bannerReadTimeout``maxBannerBytes`
- **THEN** 未显式覆盖对应字段的 tcp target SHALL 使用 defaults.tcp 中的值
#### Scenario: defaults.http.method 触发校验错误
- **WHEN** 配置文件中出现 `defaults.http.method` 字段
- **THEN** 系统 SHALL 以配置错误退出,提示 defaults.http 中存在未知字段 method
#### Scenario: per-target http.method 仍然有效 #### Scenario: per-target http.method 仍然有效
- **WHEN** HTTP target 配置 `http.method: POST` - **WHEN** HTTP target 配置 `http.method: POST`
- **THEN** 系统 SHALL 使用 POST 作为该 target 的请求方法 - **THEN** 系统 SHALL 使用 POST 作为该 target 的请求方法
#### Scenario: 未配置 http.method 使用内置默认值 #### Scenario: 未配置 http.method 使用内置默认值
- **WHEN** HTTP target 未配置 `http.method` 且 defaults.http 中无 method 字段 - **WHEN** HTTP target 未配置 `http.method`
- **THEN** 系统 SHALL 使用内置默认值 GET 作为该 target 的请求方法 - **THEN** 系统 SHALL 使用内置默认值 GET 作为该 target 的请求方法
#### Scenario: 最简 icmp 配置文件解析 #### Scenario: 最简 icmp 配置文件解析
- **WHEN** 系统读取只包含一个 `type: icmp` target`id``icmp.host`)的 YAML 配置文件 - **WHEN** 系统读取只包含一个 `type: icmp` target`id``icmp.host`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default", icmp.count=3, icmp.packetSize=56并保留 name=null、description=null - **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group=default, icmp.count=3, icmp.packetSize=56并保留 name=null、description=null
#### Scenario: 最简 udp 配置文件解析 #### Scenario: 最简 udp 配置文件解析
- **WHEN** 系统读取只包含一个 `type: udp` target`id``udp.host``udp.port`)的 YAML 配置文件 - **WHEN** 系统读取只包含一个 `type: udp` target`id``udp.host``udp.port`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default", udp.payload="", udp.encoding="text", udp.responseEncoding="text", udp.maxResponseBytes=4096并保留 name=null、description=null - **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group=default, udp.payload=空字符串, udp.encoding=text, udp.responseEncoding=text, udp.maxResponseBytes=4096并保留 name=null、description=null
#### Scenario: defaults.udp 配置默认值
- **WHEN** YAML 配置中 defaults.udp 设置 `encoding``responseEncoding``maxResponseBytes`
- **THEN** 未显式覆盖对应字段的 udp target SHALL 使用 defaults.udp 中的值
#### Scenario: 最简 llm 配置文件解析 #### Scenario: 最简 llm 配置文件解析
- **WHEN** 系统读取只包含一个 `type: llm` target`id``llm.provider``llm.url``llm.model``llm.prompt`)的 YAML 配置文件 - **WHEN** 系统读取只包含一个 `type: llm` target`id``llm.provider``llm.url``llm.model``llm.prompt`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定字段interval=30s, timeout=10s, group="default", llm.mode="http", llm.key="", llm.ignoreSSL=false, llm.options.maxOutputTokens=16, llm.options.temperature=0并保留 name=null、description=null - **THEN** 系统 SHALL 使用内置默认值填充未指定字段interval=30s, timeout=10s, group=default, llm.mode=http, llm.key=空字符串, llm.ignoreSSL=false, llm.options.maxOutputTokens=16, llm.options.temperature=0并保留 name=null、description=null
#### Scenario: defaults.llm 配置默认值
- **WHEN** YAML 配置中 defaults.llm 设置 `mode``headers``ignoreSSL``options``providerOptions`
- **THEN** 未显式覆盖对应字段的 llm target SHALL 使用 defaults.llm 中的值
### Requirement: CLI 参数 ### Requirement: CLI 参数
系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。 系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。
@@ -126,6 +112,10 @@
- **WHEN** YAML 中某个 target 缺少 id 或 type 字段 - **WHEN** YAML 中某个 target 缺少 id 或 type 字段
- **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段 - **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段
#### Scenario: 顶层 defaults 字段非法
- **WHEN** YAML 配置文件声明顶层 `defaults`
- **THEN** 系统 SHALL 以配置错误退出,提示 `defaults` 是未知字段
#### Scenario: HTTP target 缺少 url #### Scenario: HTTP target 缺少 url
- **WHEN** YAML 中某个 target 配置 `type: http` 但缺少 `http.url` - **WHEN** YAML 中某个 target 配置 `type: http` 但缺少 `http.url`
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 http.url 字段 - **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 http.url 字段
@@ -203,7 +193,7 @@
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 http.body 必须为字符串 - **THEN** 系统 SHALL 以错误退出,提示该 target 的 http.body 必须为字符串
#### Scenario: maxBodyBytes 数字非法 #### Scenario: maxBodyBytes 数字非法
- **WHEN** YAML 中某个 HTTP target 的 `http.maxBodyBytes` 或 defaults.http.maxBodyBytes 是负数、非整数或非安全整数 - **WHEN** YAML 中某个 HTTP target 的 `http.maxBodyBytes` 是负数、非整数或非安全整数
- **THEN** 系统 SHALL 以错误退出,提示 maxBodyBytes 必须为非负安全整数字节数或合法 size 字符串 - **THEN** 系统 SHALL 以错误退出,提示 maxBodyBytes 必须为非负安全整数字节数或合法 size 字符串
#### Scenario: status 模式非法 #### Scenario: status 模式非法
@@ -291,7 +281,7 @@
- **THEN** 系统 SHALL 以错误退出,提示未知字段所在路径 - **THEN** 系统 SHALL 以错误退出,提示未知字段所在路径
#### Scenario: 动态 headers 字段允许 #### Scenario: 动态 headers 字段允许
- **WHEN** YAML 中 `http.headers``defaults.http.headers``expect.headers` 包含任意 header 名称,且对应值符合契约 - **WHEN** YAML 中 `http.headers``expect.headers` 包含任意 header 名称,且对应值符合契约
- **THEN** 系统 SHALL 接受这些动态 header 名称 - **THEN** 系统 SHALL 接受这些动态 header 名称
#### Scenario: 动态 env 字段允许 #### Scenario: 动态 env 字段允许
@@ -312,7 +302,7 @@
#### Scenario: 导出配置 JSON Schema #### Scenario: 导出配置 JSON Schema
- **WHEN** 仓库生成或检查配置契约 - **WHEN** 仓库生成或检查配置契约
- **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前公共 fragments 和已注册 checker fragments 组装出的完整 schema 一致(包含 variables 段和 target 的 id/name 字段)。所有 `RawValueExpectation` 字段的 schema SHALL 声明为 `anyOf: [primitiveValue, matcherObject]` 联合类型,`RawKeyedExpectations` 的 dynamic value schema SHALL 复用 `RawValueExpectation` - **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前公共 fragments 和已注册 checker fragments 组装出的完整 schema 一致(包含 variables 段和 target 的 id/name 字段,且不包含顶层 defaults)。所有 `RawValueExpectation` 字段的 schema SHALL 声明为 `anyOf: [primitiveValue, matcherObject]` 联合类型,`RawKeyedExpectations` 的 dynamic value schema SHALL 复用 `RawValueExpectation`
#### Scenario: JSON Schema RawValueExpectation 接受原始值 #### Scenario: JSON Schema RawValueExpectation 接受原始值
- **WHEN** 使用 JSON Schema 校验配置文件中 RawValueExpectation 字段值为数字 `5000` - **WHEN** 使用 JSON Schema 校验配置文件中 RawValueExpectation 字段值为数字 `5000`
@@ -431,7 +421,7 @@
- **THEN** 系统 SHALL 接受该配置并在运行期使用完整执行耗时进行 matcher 校验 - **THEN** 系统 SHALL 接受该配置并在运行期使用完整执行耗时进行 matcher 校验
#### Scenario: 动态 headers 字段允许 #### Scenario: 动态 headers 字段允许
- **WHEN** YAML 中 `http.headers``defaults.http.headers``llm.headers``defaults.llm.headers``expect.headers` 包含任意 header 名称,且对应值符合契约 - **WHEN** YAML 中 `http.headers``llm.headers``expect.headers` 包含任意 header 名称,且对应值符合契约
- **THEN** 系统 SHALL 接受这些动态 header 名称 - **THEN** 系统 SHALL 接受这些动态 header 名称
#### Scenario: ContentExpectations 字段必须为数组 #### Scenario: ContentExpectations 字段必须为数组
@@ -546,11 +536,11 @@
- **THEN** 系统 SHALL 以配置错误退出,提示 tcp.readBanner 必须为布尔值 - **THEN** 系统 SHALL 以配置错误退出,提示 tcp.readBanner 必须为布尔值
#### Scenario: tcp bannerReadTimeout 非法 #### Scenario: tcp bannerReadTimeout 非法
- **WHEN** YAML 中 tcp target 或 defaults.tcp `bannerReadTimeout` 不是非负有限数字 - **WHEN** YAML 中 tcp target 的 `bannerReadTimeout` 不是非负有限数字
- **THEN** 系统 SHALL 以配置错误退出,提示 bannerReadTimeout 格式错误 - **THEN** 系统 SHALL 以配置错误退出,提示 bannerReadTimeout 格式错误
#### Scenario: tcp maxBannerBytes 非法 #### Scenario: tcp maxBannerBytes 非法
- **WHEN** YAML 中 tcp target 或 defaults.tcp `maxBannerBytes` 不是合法 size 值 - **WHEN** YAML 中 tcp target 的 `maxBannerBytes` 不是合法 size 值
- **THEN** 系统 SHALL 以配置错误退出,提示 maxBannerBytes 格式错误 - **THEN** 系统 SHALL 以配置错误退出,提示 maxBannerBytes 格式错误
#### Scenario: tcp expect connected 类型非法 #### Scenario: tcp expect connected 类型非法
@@ -569,12 +559,8 @@
- **WHEN** YAML 中 tcp target 的 `tcp` 分组包含 `tls: true` 等未知字段 - **WHEN** YAML 中 tcp target 的 `tcp` 分组包含 `tls: true` 等未知字段
- **THEN** 系统 SHALL 以配置错误退出,提示 tcp 分组包含未知字段 - **THEN** 系统 SHALL 以配置错误退出,提示 tcp 分组包含未知字段
#### Scenario: defaults.tcp 未知字段失败
- **WHEN** YAML 中 defaults.tcp 包含 `host` 或其他非默认字段
- **THEN** 系统 SHALL 以配置错误退出,提示 defaults.tcp 包含未知字段
### Requirement: LLM 配置校验 ### Requirement: LLM 配置校验
系统 SHALL 在启动期对 llm checker 的配置契约和语义执行严格校验。LLM target 的 `llm` 分组 SHALL 只允许 `provider``url``model``prompt``mode``key``authToken``headers``ignoreSSL``options``providerOptions` 字段。`defaults.llm` 分组 SHALL 只允许 `mode``headers``ignoreSSL``options``providerOptions` 字段。LLM expect SHALL 只允许 `status``headers``output``finishReason``rawFinishReason``usage``stream``durationMs` 字段。`expect.output` MUST 为 `RawContentExpectations` 数组。`expect.finishReason``expect.rawFinishReason``expect.usage.*``expect.stream.firstTokenMs``expect.durationMs` SHALL 使用 `RawValueExpectation`。未知字段、非法 provider、非法 URL、非法 mode、非法认证组合、非法 options、非法 output expectation 和 `mode: http` 下配置 `expect.stream` MUST 导致启动期配置错误。语义校验 MUST NOT 修改 Raw llm expect 输入。 系统 SHALL 在启动期对 llm checker 的配置契约和语义执行严格校验。LLM target 的 `llm` 分组 SHALL 只允许 `provider``url``model``prompt``mode``key``authToken``headers``ignoreSSL``options``providerOptions` 字段。LLM expect SHALL 只允许 `status``headers``output``finishReason``rawFinishReason``usage``stream``durationMs` 字段。`expect.output` MUST 为 `RawContentExpectations` 数组。`expect.finishReason``expect.rawFinishReason``expect.usage.*``expect.stream.firstTokenMs``expect.durationMs` SHALL 使用 `RawValueExpectation`。未知字段、非法 provider、非法 URL、非法 mode、非法认证组合、非法 options、非法 output expectation 和 `mode: http` 下配置 `expect.stream` MUST 导致启动期配置错误。语义校验 MUST NOT 修改 Raw llm expect 输入。
#### Scenario: llm provider 非法 #### Scenario: llm provider 非法
- **WHEN** YAML 中 llm target 的 `llm.provider` 不是 `openai``openai-responses``anthropic` - **WHEN** YAML 中 llm target 的 `llm.provider` 不是 `openai``openai-responses``anthropic`
@@ -593,15 +579,15 @@
- **THEN** 系统 SHALL 以配置错误退出,提示 llm.prompt 必须为非空字符串 - **THEN** 系统 SHALL 以配置错误退出,提示 llm.prompt 必须为非空字符串
#### Scenario: llm mode 非法 #### Scenario: llm mode 非法
- **WHEN** YAML 中 llm target 或 defaults.llm `mode` 不是 `http``stream` - **WHEN** YAML 中 llm target 的 `mode` 不是 `http``stream`
- **THEN** 系统 SHALL 以配置错误退出,提示 llm.mode 不合法 - **THEN** 系统 SHALL 以配置错误退出,提示 llm.mode 不合法
#### Scenario: llm headers 类型非法 #### Scenario: llm headers 类型非法
- **WHEN** YAML 中 llm target 或 defaults.llm `headers` 不是对象,或任一 header 值不是字符串 - **WHEN** YAML 中 llm target 的 `headers` 不是对象,或任一 header 值不是字符串
- **THEN** 系统 SHALL 以配置错误退出,提示 llm.headers 格式错误 - **THEN** 系统 SHALL 以配置错误退出,提示 llm.headers 格式错误
#### Scenario: llm ignoreSSL 类型非法 #### Scenario: llm ignoreSSL 类型非法
- **WHEN** YAML 中 llm target 或 defaults.llm `ignoreSSL` 不是布尔值 - **WHEN** YAML 中 llm target 的 `ignoreSSL` 不是布尔值
- **THEN** 系统 SHALL 以配置错误退出,提示 llm.ignoreSSL 必须为布尔值 - **THEN** 系统 SHALL 以配置错误退出,提示 llm.ignoreSSL 必须为布尔值
#### Scenario: llm authToken provider 非法 #### Scenario: llm authToken provider 非法
@@ -613,11 +599,11 @@
- **THEN** 系统 SHALL 以配置错误退出,提示 key 与 authToken 不能同时配置 - **THEN** 系统 SHALL 以配置错误退出,提示 key 与 authToken 不能同时配置
#### Scenario: llm options 非法 #### Scenario: llm options 非法
- **WHEN** YAML 中 llm target 或 defaults.llm `options.maxOutputTokens` 不是正整数,`options.temperature`/`topP`/`topK`/`presencePenalty`/`frequencyPenalty`/`seed` 类型不合法,或 `options.stopSequences` 不是字符串数组 - **WHEN** YAML 中 llm target 的 `options.maxOutputTokens` 不是正整数,`options.temperature`/`topP`/`topK`/`presencePenalty`/`frequencyPenalty`/`seed` 类型不合法,或 `options.stopSequences` 不是字符串数组
- **THEN** 系统 SHALL 以配置错误退出,提示 llm.options 格式错误 - **THEN** 系统 SHALL 以配置错误退出,提示 llm.options 格式错误
#### Scenario: llm providerOptions 非法 #### Scenario: llm providerOptions 非法
- **WHEN** YAML 中 llm target 或 defaults.llm `providerOptions` 不是 JSON object - **WHEN** YAML 中 llm target 的 `providerOptions` 不是 JSON object
- **THEN** 系统 SHALL 以配置错误退出,提示 llm.providerOptions 格式错误 - **THEN** 系统 SHALL 以配置错误退出,提示 llm.providerOptions 格式错误
#### Scenario: llm 禁止字段失败 #### Scenario: llm 禁止字段失败

View File

@@ -5,212 +5,6 @@
"targets" "targets"
], ],
"properties": { "properties": {
"defaults": {
"additionalProperties": false,
"type": "object",
"properties": {
"interval": {
"type": "string"
},
"timeout": {
"type": "string"
},
"http": {
"additionalProperties": false,
"type": "object",
"properties": {
"headers": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"maxBodyBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
}
}
},
"cmd": {
"additionalProperties": false,
"type": "object",
"properties": {
"cwd": {
"type": "string"
},
"maxOutputBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
}
}
},
"db": {
"additionalProperties": false,
"type": "object",
"properties": {}
},
"tcp": {
"additionalProperties": false,
"type": "object",
"properties": {
"bannerReadTimeout": {
"minimum": 0,
"type": "number"
},
"maxBannerBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
}
}
},
"icmp": {
"additionalProperties": false,
"type": "object",
"properties": {}
},
"udp": {
"additionalProperties": false,
"type": "object",
"properties": {
"encoding": {
"anyOf": [
{
"const": "text",
"type": "string"
},
{
"const": "hex",
"type": "string"
},
{
"const": "base64",
"type": "string"
}
]
},
"maxResponseBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
},
"responseEncoding": {
"anyOf": [
{
"const": "text",
"type": "string"
},
{
"const": "hex",
"type": "string"
},
{
"const": "base64",
"type": "string"
}
]
}
}
},
"llm": {
"additionalProperties": false,
"type": "object",
"properties": {
"headers": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"ignoreSSL": {
"type": "boolean"
},
"mode": {
"anyOf": [
{
"const": "http",
"type": "string"
},
{
"const": "stream",
"type": "string"
}
]
},
"options": {
"additionalProperties": false,
"type": "object",
"properties": {
"frequencyPenalty": {
"type": "number"
},
"maxOutputTokens": {
"minimum": 1,
"type": "integer"
},
"presencePenalty": {
"type": "number"
},
"seed": {
"type": "number"
},
"stopSequences": {
"type": "array",
"items": {
"type": "string"
}
},
"temperature": {
"type": "number"
},
"topK": {
"type": "number"
},
"topP": {
"type": "number"
}
}
},
"providerOptions": {
"type": "object",
"patternProperties": {
"^(.*)$": {
"additionalProperties": true,
"type": "object",
"properties": {}
}
}
}
}
}
}
},
"probes": { "probes": {
"additionalProperties": false, "additionalProperties": false,
"type": "object", "type": "object",

View File

@@ -22,14 +22,6 @@ probes:
execution: execution:
maxConcurrentChecks: 20 maxConcurrentChecks: 20
defaults:
interval: "30s"
timeout: "10s"
http:
maxBodyBytes: "10MB"
cmd:
maxOutputBytes: "1MB"
variables: variables:
env_name: "演示" env_name: "演示"
httpbin_base: "https://httpbin.org" httpbin_base: "https://httpbin.org"

View File

@@ -3,7 +3,6 @@ import { dirname, resolve } from "node:path";
import type { ConfigValidationIssue } from "./schema/issues"; import type { ConfigValidationIssue } from "./schema/issues";
import type { import type {
DefaultsConfig,
ExecutionConfig, ExecutionConfig,
LoggingConfig, LoggingConfig,
LogLevel, LogLevel,
@@ -90,7 +89,6 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
const server = validated.server ?? {}; const server = validated.server ?? {};
const listen = server.listen ?? {}; const listen = server.listen ?? {};
const storage = server.storage ?? {}; const storage = server.storage ?? {};
const defaults = validated.defaults ?? {};
const host = listen.host ?? DEFAULT_HOST; const host = listen.host ?? DEFAULT_HOST;
const port = listen.port ?? DEFAULT_PORT; const port = listen.port ?? DEFAULT_PORT;
@@ -109,11 +107,11 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
throwConfigIssues(dedupeIssues(allRuntimeIssues)); throwConfigIssues(dedupeIssues(allRuntimeIssues));
} }
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL); const defaultIntervalMs = parseDuration(DEFAULT_INTERVAL);
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT); const defaultTimeoutMs = parseDuration(DEFAULT_TIMEOUT);
const targets: ResolvedTargetBase[] = validated.targets.map((target) => const targets: ResolvedTargetBase[] = validated.targets.map((target) =>
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir), resolveTarget(target, defaultIntervalMs, defaultTimeoutMs, configDir),
); );
return { configDir, dataDir, host, logging, maxConcurrentChecks, port, retentionMs, targets }; return { configDir, dataDir, host, logging, maxConcurrentChecks, port, retentionMs, targets };
@@ -193,16 +191,15 @@ function resolveRetention(storage: ServerStorageConfig): number {
function resolveTarget( function resolveTarget(
target: RawTargetConfig, target: RawTargetConfig,
defaults: DefaultsConfig,
defaultIntervalMs: number, defaultIntervalMs: number,
defaultTimeoutMs: number, defaultTimeoutMs: number,
configDir: string, configDir: string,
): ResolvedTargetBase { ): ResolvedTargetBase {
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL); const intervalMs = parseDuration(target.interval ?? DEFAULT_INTERVAL);
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT); const timeoutMs = parseDuration(target.timeout ?? DEFAULT_TIMEOUT);
const checker = checkerRegistry.get(target.type); const checker = checkerRegistry.get(target.type);
const result = checker.resolve(target, { configDir, defaultIntervalMs, defaults, defaultTimeoutMs }); const result = checker.resolve(target, { configDir, defaultIntervalMs, defaultTimeoutMs });
result.intervalMs = intervalMs; result.intervalMs = intervalMs;
result.timeoutMs = timeoutMs; result.timeoutMs = timeoutMs;
@@ -275,11 +272,9 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
} }
for (const checker of checkerRegistry.definitions) { for (const checker of checkerRegistry.definitions) {
issues.push(...checker.validate({ defaults: config.defaults ?? {}, targets: config.targets })); issues.push(...checker.validate({ targets: config.targets }));
} }
validateDurationValue(config.defaults?.interval, "defaults.interval", issues);
validateDurationValue(config.defaults?.timeout, "defaults.timeout", issues);
validateDurationValue( validateDurationValue(
isString(config.server?.storage?.retention) ? config.server.storage.retention : undefined, isString(config.server?.storage?.retention) ? config.server.storage.retention : undefined,
"server.storage.retention", "server.storage.retention",

View File

@@ -209,12 +209,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCommandTarget { resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCommandTarget {
const t = target as RawTargetConfig & { cmd: CommandTargetConfig; type: "cmd" }; const t = target as RawTargetConfig & { cmd: CommandTargetConfig; type: "cmd" };
const cmdDefaults = context.defaults["cmd"] as undefined | { cwd?: string; maxOutputBytes?: string };
const cwd = t.cmd.cwd ?? cmdDefaults?.cwd ?? "."; const cwd = t.cmd.cwd ?? ".";
const resolvedCwd = resolve(context.configDir, cwd); const resolvedCwd = resolve(context.configDir, cwd);
const maxOutputBytes = parseSize(t.cmd.maxOutputBytes ?? cmdDefaults?.maxOutputBytes ?? "100MB"); const maxOutputBytes = parseSize(t.cmd.maxOutputBytes ?? "100MB");
const env = { ...process.env, ...(t.cmd.env ?? {}) } as Record<string, string>; const env = { ...process.env, ...(t.cmd.env ?? {}) } as Record<string, string>;

View File

@@ -20,13 +20,6 @@ export const commandCheckerSchemas: CheckerSchemas = {
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
defaults: Type.Object(
{
cwd: Type.Optional(Type.String()),
maxOutputBytes: Type.Optional(sizeSchema),
},
{ additionalProperties: false },
),
expect: Type.Object( expect: Type.Object(
{ {
durationMs: Type.Optional(createRawValueExpectationSchema()), durationMs: Type.Optional(createRawValueExpectationSchema()),

View File

@@ -6,11 +6,6 @@ import type {
} from "../../expect/types"; } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types"; import type { ResolvedTargetBase } from "../../types";
export interface CommandDefaultsConfig {
cwd?: string;
maxOutputBytes?: string;
}
export interface CommandTargetConfig { export interface CommandTargetConfig {
args?: string[]; args?: string[];
cwd?: string; cwd?: string;

View File

@@ -9,12 +9,6 @@ import { parseSize } from "../../utils";
export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] { export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const defaults =
isPlainRecord(input.defaults) && isPlainRecord(input.defaults["cmd"]) ? input.defaults["cmd"] : undefined;
if (isSizeInput(defaults?.["maxOutputBytes"])) {
issues.push(...validateSizeValue(defaults["maxOutputBytes"], "defaults.cmd.maxOutputBytes"));
}
for (let i = 0; i < input.targets.length; i++) { for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown; const target = input.targets[i] as unknown;

View File

@@ -20,7 +20,6 @@ export const dbCheckerSchemas: CheckerSchemas = {
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
defaults: Type.Object({}, { additionalProperties: false }),
expect: Type.Object( expect: Type.Object(
{ {
durationMs: Type.Optional(createRawValueExpectationSchema()), durationMs: Type.Optional(createRawValueExpectationSchema()),

View File

@@ -175,12 +175,9 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedHttpTarget { resolve(target: RawTargetConfig, context: ResolveContext): ResolvedHttpTarget {
const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" }; const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" };
const httpDefaults = context.defaults["http"] as
| undefined
| { headers?: Record<string, string>; maxBodyBytes?: string };
const method = t.http.method ?? "GET"; const method = t.http.method ?? "GET";
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB"); const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? "100MB");
const rawExpect = target.expect as RawHttpExpectConfig | undefined; const rawExpect = target.expect as RawHttpExpectConfig | undefined;
const resolvedExpect: ResolvedHttpExpectConfig = rawExpect const resolvedExpect: ResolvedHttpExpectConfig = rawExpect
@@ -198,7 +195,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
group: target.group ?? "default", group: target.group ?? "default",
http: { http: {
body: t.http.body, body: t.http.body,
headers: { ...(httpDefaults?.headers ?? {}), ...(t.http.headers ?? {}) }, headers: { ...(t.http.headers ?? {}) },
ignoreSSL: t.http.ignoreSSL ?? false, ignoreSSL: t.http.ignoreSSL ?? false,
maxBodyBytes, maxBodyBytes,
maxRedirects: t.http.maxRedirects ?? 0, maxRedirects: t.http.maxRedirects ?? 0,

View File

@@ -25,13 +25,6 @@ export const httpCheckerSchemas: CheckerSchemas = {
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
defaults: Type.Object(
{
headers: Type.Optional(stringMapSchema),
maxBodyBytes: Type.Optional(sizeSchema),
},
{ additionalProperties: false },
),
expect: Type.Object( expect: Type.Object(
{ {
body: Type.Optional(createRawContentExpectationsSchema()), body: Type.Optional(createRawContentExpectationsSchema()),

View File

@@ -8,12 +8,6 @@ import type {
} from "../../expect/types"; } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types"; import type { ResolvedTargetBase } from "../../types";
export interface HttpDefaultsConfig {
headers?: Record<string, string>;
maxBodyBytes?: string;
method?: string;
}
export interface HttpTargetConfig { export interface HttpTargetConfig {
body?: string; body?: string;
headers?: Record<string, string>; headers?: Record<string, string>;

View File

@@ -16,12 +16,6 @@ const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
export function validateHttpConfig(input: CheckerValidationInput): ConfigValidationIssue[] { export function validateHttpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const defaults =
isPlainRecord(input.defaults) && isPlainRecord(input.defaults["http"]) ? input.defaults["http"] : undefined;
if (isSizeInput(defaults?.["maxBodyBytes"])) {
issues.push(...validateSizeValue(defaults["maxBodyBytes"], "defaults.http.maxBodyBytes"));
}
for (let i = 0; i < input.targets.length; i++) { for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown; const target = input.targets[i] as unknown;

View File

@@ -13,7 +13,6 @@ export const icmpCheckerSchemas: CheckerSchemas = {
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
defaults: Type.Object({}, { additionalProperties: false }),
expect: Type.Object( expect: Type.Object(
{ {
alive: Type.Optional(Type.Boolean()), alive: Type.Optional(Type.Boolean()),

View File

@@ -9,19 +9,6 @@ import { issue, joinPath } from "../../schema/issues";
export function validatePingConfig(input: CheckerValidationInput): ConfigValidationIssue[] { export function validatePingConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const defaults = input.defaults["icmp"];
if (defaults !== undefined && defaults !== null) {
const targetName = "defaults.icmp";
if (!isPlainRecord(defaults)) {
issues.push(issue("invalid-type", "defaults.icmp", "必须为对象", targetName));
} else {
const icmpDefaults = defaults;
for (const key of Object.keys(icmpDefaults)) {
issues.push(issue("unknown-field", joinPath("defaults.icmp", key), "是未知字段", targetName));
}
}
}
for (let i = 0; i < input.targets.length; i++) { for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown; const target = input.targets[i] as unknown;
if (!isPlainRecord(target)) continue; if (!isPlainRecord(target)) continue;

View File

@@ -1,5 +1,3 @@
import type { JSONObject } from "@ai-sdk/provider";
import { APICallError, generateText, streamText } from "ai"; import { APICallError, generateText, streamText } from "ai";
import { isError } from "es-toolkit"; import { isError } from "es-toolkit";
@@ -133,43 +131,27 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedLlmTarget { resolve(target: RawTargetConfig, context: ResolveContext): ResolvedLlmTarget {
const t = target as RawTargetConfig & { llm: LlmTargetConfig; type: "llm" }; const t = target as RawTargetConfig & { llm: LlmTargetConfig; type: "llm" };
const llmDefaults = context.defaults["llm"] as
| undefined
| {
headers?: Record<string, string>;
ignoreSSL?: boolean;
mode?: string;
options?: Record<string, unknown>;
providerOptions?: Record<string, Record<string, unknown>>;
};
const resolvedConfig = { const resolvedConfig = {
authToken: t.llm.authToken, authToken: t.llm.authToken,
headers: { ...(llmDefaults?.headers ?? {}), ...(t.llm.headers ?? {}) }, headers: { ...(t.llm.headers ?? {}) },
ignoreSSL: t.llm.ignoreSSL ?? llmDefaults?.ignoreSSL ?? false, ignoreSSL: t.llm.ignoreSSL ?? false,
key: t.llm.key ?? "", key: t.llm.key ?? "",
mode: (t.llm.mode ?? llmDefaults?.mode ?? "http") as "http" | "stream", mode: t.llm.mode ?? "http",
model: t.llm.model, model: t.llm.model,
options: { options: {
frequencyPenalty: frequencyPenalty: t.llm.options?.frequencyPenalty,
t.llm.options?.frequencyPenalty ?? (llmDefaults?.options?.["frequencyPenalty"] as number | undefined), maxOutputTokens: t.llm.options?.maxOutputTokens ?? 16,
maxOutputTokens: presencePenalty: t.llm.options?.presencePenalty,
t.llm.options?.maxOutputTokens ?? (llmDefaults?.options?.["maxOutputTokens"] as number | undefined) ?? 16, seed: t.llm.options?.seed,
presencePenalty: stopSequences: t.llm.options?.stopSequences,
t.llm.options?.presencePenalty ?? (llmDefaults?.options?.["presencePenalty"] as number | undefined), temperature: t.llm.options?.temperature ?? 0,
seed: t.llm.options?.seed ?? (llmDefaults?.options?.["seed"] as number | undefined), topK: t.llm.options?.topK,
stopSequences: topP: t.llm.options?.topP,
t.llm.options?.stopSequences ?? (llmDefaults?.options?.["stopSequences"] as string[] | undefined),
temperature: t.llm.options?.temperature ?? (llmDefaults?.options?.["temperature"] as number | undefined) ?? 0,
topK: t.llm.options?.topK ?? (llmDefaults?.options?.["topK"] as number | undefined),
topP: t.llm.options?.topP ?? (llmDefaults?.options?.["topP"] as number | undefined),
}, },
prompt: t.llm.prompt, prompt: t.llm.prompt,
provider: t.llm.provider, provider: t.llm.provider,
providerOptions: { providerOptions: { ...(t.llm.providerOptions ?? {}) },
...((llmDefaults?.providerOptions ?? {}) as Record<string, JSONObject>),
...(t.llm.providerOptions ?? {}),
},
url: t.llm.url, url: t.llm.url,
}; };

View File

@@ -43,16 +43,6 @@ export const llmCheckerSchemas: CheckerSchemas = {
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
defaults: Type.Object(
{
headers: Type.Optional(stringMapSchema),
ignoreSSL: Type.Optional(Type.Boolean()),
mode: Type.Optional(Type.Union([Type.Literal("http"), Type.Literal("stream")])),
options: Type.Optional(createLlmOptionsSchema()),
providerOptions: Type.Optional(Type.Record(Type.String(), Type.Object({}, { additionalProperties: true }))),
},
{ additionalProperties: false },
),
expect: Type.Object( expect: Type.Object(
{ {
durationMs: Type.Optional(createRawValueExpectationSchema()), durationMs: Type.Optional(createRawValueExpectationSchema()),

View File

@@ -23,14 +23,6 @@ export interface LlmCheckObservation {
warnings: string[]; warnings: string[];
} }
export interface LlmDefaultsConfig {
headers?: Record<string, string>;
ignoreSSL?: boolean;
mode?: LlmMode;
options?: LlmOptions;
providerOptions?: Record<string, JSONObject>;
}
export interface LlmHttpMetadata { export interface LlmHttpMetadata {
headers: Record<string, string>; headers: Record<string, string>;
status: number; status: number;

View File

@@ -17,12 +17,6 @@ const ALLOWED_PROVIDERS = new Set(["anthropic", "openai", "openai-responses"]);
export function validateLlmConfig(input: CheckerValidationInput): ConfigValidationIssue[] { export function validateLlmConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const defaults =
isPlainRecord(input.defaults) && isPlainRecord(input.defaults["llm"]) ? input.defaults["llm"] : undefined;
if (defaults) {
issues.push(...validateLlmDefaults(defaults, "defaults.llm"));
}
for (let i = 0; i < input.targets.length; i++) { for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown; const target = input.targets[i] as unknown;
@@ -39,28 +33,6 @@ function getTargetName(target: Record<string, unknown>): string | undefined {
return isString(target["id"]) ? target["id"] : undefined; return isString(target["id"]) ? target["id"] : undefined;
} }
function validateLlmDefaults(defaults: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
if (defaults["mode"] !== undefined && !ALLOWED_MODES.has(defaults["mode"] as string)) {
issues.push(issue("invalid-type", joinPath(path, "mode"), "必须为 http 或 stream"));
}
if (defaults["ignoreSSL"] !== undefined && !isBoolean(defaults["ignoreSSL"])) {
issues.push(issue("invalid-type", joinPath(path, "ignoreSSL"), "必须为布尔值"));
}
if (defaults["headers"] !== undefined) {
issues.push(...validateStringMap(defaults["headers"], joinPath(path, "headers")));
}
if (defaults["options"] !== undefined) {
issues.push(...validateLlmOptions(defaults["options"], joinPath(path, "options")));
}
if (defaults["providerOptions"] !== undefined) {
issues.push(...validateProviderOptions(defaults["providerOptions"], joinPath(path, "providerOptions")));
}
return issues;
}
function validateLlmExpect( function validateLlmExpect(
target: Record<string, unknown>, target: Record<string, unknown>,
path: string, path: string,

View File

@@ -206,12 +206,9 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTcpTarget { resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTcpTarget {
const t = target as RawTargetConfig & { tcp: TcpTargetConfig; type: "tcp" }; const t = target as RawTargetConfig & { tcp: TcpTargetConfig; type: "tcp" };
const tcpDefaults = context.defaults["tcp"] as
| undefined
| { bannerReadTimeout?: number; maxBannerBytes?: number | string };
const maxBannerBytes = parseSize(t.tcp.maxBannerBytes ?? tcpDefaults?.maxBannerBytes ?? DEFAULT_MAX_BANNER_BYTES); const maxBannerBytes = parseSize(t.tcp.maxBannerBytes ?? DEFAULT_MAX_BANNER_BYTES);
const bannerReadTimeout = t.tcp.bannerReadTimeout ?? tcpDefaults?.bannerReadTimeout ?? DEFAULT_BANNER_READ_TIMEOUT; const bannerReadTimeout = t.tcp.bannerReadTimeout ?? DEFAULT_BANNER_READ_TIMEOUT;
const rawExpect = target.expect as RawTcpExpectConfig | undefined; const rawExpect = target.expect as RawTcpExpectConfig | undefined;
const resolvedExpect: ResolvedTcpExpectConfig = rawExpect const resolvedExpect: ResolvedTcpExpectConfig = rawExpect

View File

@@ -19,13 +19,6 @@ export const tcpCheckerSchemas: CheckerSchemas = {
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
defaults: Type.Object(
{
bannerReadTimeout: Type.Optional(Type.Number({ minimum: 0 })),
maxBannerBytes: Type.Optional(sizeSchema),
},
{ additionalProperties: false },
),
expect: Type.Object( expect: Type.Object(
{ {
banner: Type.Optional(createRawContentExpectationsSchema()), banner: Type.Optional(createRawContentExpectationsSchema()),

View File

@@ -37,11 +37,6 @@ export interface ResolvedTcpTarget extends ResolvedTargetBase {
type: "tcp"; type: "tcp";
} }
export interface TcpDefaultsConfig {
bannerReadTimeout?: number;
maxBannerBytes?: number | string;
}
export interface TcpTargetConfig { export interface TcpTargetConfig {
bannerReadTimeout?: number; bannerReadTimeout?: number;
host: string; host: string;

View File

@@ -9,8 +9,6 @@ import { issue, joinPath } from "../../schema/issues";
export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[] { export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
issues.push(...validateTcpDefaults(input));
for (let i = 0; i < input.targets.length; i++) { for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown; const target = input.targets[i] as unknown;
if (!isPlainRecord(target)) continue; if (!isPlainRecord(target)) continue;
@@ -30,40 +28,6 @@ function isNonNegativeFiniteNumber(value: unknown): boolean {
return isNumber(value) && Number.isFinite(value) && value >= 0; return isNumber(value) && Number.isFinite(value) && value >= 0;
} }
function validateTcpDefaults(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults = input.defaults["tcp"];
if (defaults === undefined || defaults === null || !isPlainRecord(defaults)) return issues;
const targetName = "defaults.tcp";
if (defaults["bannerReadTimeout"] !== undefined && !isNonNegativeFiniteNumber(defaults["bannerReadTimeout"])) {
issues.push(issue("invalid-type", "defaults.tcp.bannerReadTimeout", "必须为非负有限数字", targetName));
}
if (defaults["maxBannerBytes"] !== undefined) {
if (
!isString(defaults["maxBannerBytes"]) &&
!(
isNumber(defaults["maxBannerBytes"]) &&
Number.isFinite(defaults["maxBannerBytes"]) &&
defaults["maxBannerBytes"] >= 0
)
) {
issues.push(issue("invalid-value", "defaults.tcp.maxBannerBytes", "必须为合法 size 值", targetName));
}
}
const allowedKeys = new Set(["bannerReadTimeout", "maxBannerBytes"]);
for (const key of Object.keys(defaults)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath("defaults.tcp", key), "是未知字段", targetName));
}
}
return issues;
}
function validateTcpExpect( function validateTcpExpect(
target: Record<string, unknown>, target: Record<string, unknown>,
path: string, path: string,
@@ -159,5 +123,3 @@ function validateTcpTarget(target: Record<string, unknown>, path: string): Confi
return issues; return issues;
} }
export { validateTcpDefaults };

View File

@@ -1,7 +1,7 @@
import type { TSchema } from "@sinclair/typebox"; import type { TSchema } from "@sinclair/typebox";
import type { ConfigValidationIssue } from "../schema/issues"; import type { ConfigValidationIssue } from "../schema/issues";
import type { CheckResult, DefaultsConfig, RawTargetConfig, ResolvedTargetBase } from "../types"; import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../types";
export type Checker<TResolved extends ResolvedTargetBase = ResolvedTargetBase> = CheckerDefinition<TResolved>; export type Checker<TResolved extends ResolvedTargetBase = ResolvedTargetBase> = CheckerDefinition<TResolved>;
@@ -22,18 +22,15 @@ export interface CheckerDefinition<TResolved extends ResolvedTargetBase = Resolv
export interface CheckerSchemas { export interface CheckerSchemas {
config: TSchema; config: TSchema;
defaults: TSchema;
expect: TSchema; expect: TSchema;
} }
export interface CheckerValidationInput { export interface CheckerValidationInput {
defaults: DefaultsConfig;
targets: RawTargetConfig[]; targets: RawTargetConfig[];
} }
export interface ResolveContext { export interface ResolveContext {
configDir: string; configDir: string;
defaultIntervalMs: number; defaultIntervalMs: number;
defaults: DefaultsConfig;
defaultTimeoutMs: number; defaultTimeoutMs: number;
} }

View File

@@ -2,13 +2,7 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { import type { RawUdpExpectConfig, ResolvedUdpExpectConfig, ResolvedUdpTarget, UdpTargetConfig } from "./types";
RawUdpExpectConfig,
ResolvedUdpExpectConfig,
ResolvedUdpTarget,
UdpDefaultsConfig,
UdpTargetConfig,
} from "./types";
import { resolveContentExpectations } from "../../expect/content"; import { resolveContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure"; import { errorFailure } from "../../expect/failure";
@@ -304,13 +298,10 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedUdpTarget { resolve(target: RawTargetConfig, context: ResolveContext): ResolvedUdpTarget {
const t = target as RawTargetConfig & { type: "udp"; udp: UdpTargetConfig }; const t = target as RawTargetConfig & { type: "udp"; udp: UdpTargetConfig };
const udpDefaults = context.defaults["udp"] as UdpDefaultsConfig | undefined;
const encoding = t.udp.encoding ?? udpDefaults?.encoding ?? "text"; const encoding = t.udp.encoding ?? "text";
const responseEncoding = t.udp.responseEncoding ?? udpDefaults?.responseEncoding ?? "text"; const responseEncoding = t.udp.responseEncoding ?? "text";
const maxResponseBytes = parseSize( const maxResponseBytes = parseSize(t.udp.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES);
t.udp.maxResponseBytes ?? udpDefaults?.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
);
const rawExpect = target.expect as RawUdpExpectConfig | undefined; const rawExpect = target.expect as RawUdpExpectConfig | undefined;
const resolvedExpect: ResolvedUdpExpectConfig = rawExpect const resolvedExpect: ResolvedUdpExpectConfig = rawExpect

View File

@@ -20,14 +20,6 @@ export const udpCheckerSchemas: CheckerSchemas = {
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
defaults: Type.Object(
{
encoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
maxResponseBytes: Type.Optional(sizeSchema),
responseEncoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
},
{ additionalProperties: false },
),
expect: Type.Object( expect: Type.Object(
{ {
durationMs: Type.Optional(createRawValueExpectationSchema()), durationMs: Type.Optional(createRawValueExpectationSchema()),

View File

@@ -44,12 +44,6 @@ export interface ResolvedUdpTarget extends ResolvedTargetBase {
udp: ResolvedUdpConfig; udp: ResolvedUdpConfig;
} }
export interface UdpDefaultsConfig {
encoding?: UdpEncoding;
maxResponseBytes?: number | string;
responseEncoding?: UdpEncoding;
}
export type UdpEncoding = "base64" | "hex" | "text"; export type UdpEncoding = "base64" | "hex" | "text";
export interface UdpTargetConfig { export interface UdpTargetConfig {

View File

@@ -11,8 +11,6 @@ const VALID_ENCODINGS = new Set(["base64", "hex", "text"]);
export function validateUdpConfig(input: CheckerValidationInput): ConfigValidationIssue[] { export function validateUdpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
issues.push(...validateUdpDefaults(input));
for (let i = 0; i < input.targets.length; i++) { for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown; const target = input.targets[i] as unknown;
if (!isPlainRecord(target)) continue; if (!isPlainRecord(target)) continue;
@@ -44,29 +42,6 @@ function validateSize(value: unknown, path: string, targetName: string | undefin
return []; return [];
} }
function validateUdpDefaults(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults = input.defaults["udp"];
if (defaults === undefined || defaults === null || !isPlainRecord(defaults)) return issues;
const targetName = "defaults.udp";
issues.push(...validateEncoding(defaults["encoding"], joinPath("defaults.udp", "encoding"), targetName));
issues.push(
...validateEncoding(defaults["responseEncoding"], joinPath("defaults.udp", "responseEncoding"), targetName),
);
issues.push(...validateSize(defaults["maxResponseBytes"], joinPath("defaults.udp", "maxResponseBytes"), targetName));
const allowedKeys = new Set(["encoding", "maxResponseBytes", "responseEncoding"]);
for (const key of Object.keys(defaults)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath("defaults.udp", key), "是未知字段", targetName));
}
}
return issues;
}
function validateUdpExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] { function validateUdpExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target); const targetName = getTargetName(target);
const expect = target["expect"]; const expect = target["expect"];

View File

@@ -34,7 +34,6 @@ export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]):
export function createProbeConfigSchema(checkers: CheckerDefinition[], external = false): TSchema { export function createProbeConfigSchema(checkers: CheckerDefinition[], external = false): TSchema {
return Type.Object( return Type.Object(
{ {
defaults: Type.Optional(createDefaultsSchema(checkers)),
probes: Type.Optional(createProbesSchema()), probes: Type.Optional(createProbesSchema()),
server: Type.Optional(createServerSchema()), server: Type.Optional(createServerSchema()),
targets: Type.Array(external ? createExternalTargetSchema(checkers) : createBaseTargetSchema(checkers), { targets: Type.Array(external ? createExternalTargetSchema(checkers) : createBaseTargetSchema(checkers), {
@@ -80,17 +79,6 @@ function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
); );
} }
function createDefaultsSchema(checkers: CheckerDefinition[]): TSchema {
const properties: Record<string, TSchema> = {
interval: Type.Optional(durationSchema),
timeout: Type.Optional(durationSchema),
};
for (const checker of checkers) {
properties[checker.configKey] = Type.Optional(checker.schemas.defaults);
}
return Type.Object(properties, { additionalProperties: false });
}
function createExternalTargetSchema(checkers: CheckerDefinition[]): TSchema { function createExternalTargetSchema(checkers: CheckerDefinition[]): TSchema {
return Type.Union(checkers.map((checker) => createTargetSchema(checker)) as [TSchema, ...TSchema[]]); return Type.Union(checkers.map((checker) => createTargetSchema(checker)) as [TSchema, ...TSchema[]]);
} }

View File

@@ -4,12 +4,6 @@ export interface CheckResult extends ApiCheckResult {
targetId: string; targetId: string;
} }
export interface DefaultsConfig {
[checkerKey: string]: unknown;
interval?: string;
timeout?: string;
}
export interface ExecutionConfig { export interface ExecutionConfig {
maxConcurrentChecks?: number; maxConcurrentChecks?: number;
} }
@@ -41,7 +35,6 @@ export interface LoggingFileRotationConfig {
export type LogLevel = "debug" | "error" | "fatal" | "info" | "trace" | "warn"; export type LogLevel = "debug" | "error" | "fatal" | "info" | "trace" | "warn";
export interface ProbeConfig { export interface ProbeConfig {
defaults?: DefaultsConfig;
probes?: ProbesConfig; probes?: ProbesConfig;
server?: ServerConfig; server?: ServerConfig;
targets: RawTargetConfig[]; targets: RawTargetConfig[];

View File

@@ -190,16 +190,6 @@ describe("loadConfig", () => {
probes: probes:
execution: execution:
maxConcurrentChecks: 5 maxConcurrentChecks: 5
defaults:
interval: "15s"
timeout: "5s"
http:
headers:
Authorization: "Bearer token"
maxBodyBytes: "50MB"
cmd:
cwd: "/tmp"
maxOutputBytes: "10MB"
targets: targets:
- name: "http-target" - name: "http-target"
id: "http-target" id: "http-target"
@@ -236,19 +226,18 @@ targets:
expect(http.type).toBe("http"); expect(http.type).toBe("http");
expect(http.http.url).toBe("http://example.com"); expect(http.http.url).toBe("http://example.com");
expect(http.http.method).toBe("POST"); expect(http.http.method).toBe("POST");
expect(http.http.headers).toEqual({ Authorization: "Bearer token" });
expect(http.http.ignoreSSL).toBe(true); expect(http.http.ignoreSSL).toBe(true);
expect(http.http.maxBodyBytes).toBe(52428800); expect(http.http.maxBodyBytes).toBe(104857600);
expect(http.http.maxRedirects).toBe(5); expect(http.http.maxRedirects).toBe(5);
expect(http.expect?.status).toEqual(["2xx", 301]); expect(http.expect?.status).toEqual(["2xx", 301]);
expect(http.intervalMs).toBe(60000); expect(http.intervalMs).toBe(60000);
expect(http.timeoutMs).toBe(5000); expect(http.timeoutMs).toBe(10000);
const cmd = config.targets[1]! as ResolvedCommandTarget; const cmd = config.targets[1]! as ResolvedCommandTarget;
expect(cmd.type).toBe("cmd"); expect(cmd.type).toBe("cmd");
expect(cmd.cmd.exec).toBe("ls"); expect(cmd.cmd.exec).toBe("ls");
expect(cmd.cmd.args).toEqual(["/tmp"]); expect(cmd.cmd.args).toEqual(["/tmp"]);
expect(cmd.cmd.maxOutputBytes).toBe(10485760); expect(cmd.cmd.maxOutputBytes).toBe(104857600);
}); });
test("name 缺省时保留为 null", async () => { test("name 缺省时保留为 null", async () => {
@@ -526,16 +515,11 @@ targets:
expect(config.dataDir).toBe(dataDir); expect(config.dataDir).toBe(dataDir);
}); });
test("per-target 覆盖 defaults", async () => { test("per-target interval 和 timeout 覆盖全局默认", async () => {
const configPath = join(tempDir, "override.yaml"); const configPath = join(tempDir, "override.yaml");
await writeFile( await writeFile(
configPath, configPath,
`defaults: `targets:
interval: "30s"
timeout: "10s"
http:
maxBodyBytes: "10MB"
targets:
- name: "override-all" - name: "override-all"
id: "override-all" id: "override-all"
type: http type: http
@@ -799,15 +783,13 @@ targets:
const configPath = join(tempDir, "bad-size.yaml"); const configPath = join(tempDir, "bad-size.yaml");
await writeFile( await writeFile(
configPath, configPath,
`defaults: `targets:
http:
maxBodyBytes: "100TB"
targets:
- name: "t" - name: "t"
id: "t" id: "t"
type: http type: http
http: http:
url: "http://a.com" url: "http://a.com"
maxBodyBytes: "100TB"
`, `,
); );
await expectConfigLoadError(configPath, "无效的 size 格式"); await expectConfigLoadError(configPath, "无效的 size 格式");
@@ -1388,9 +1370,9 @@ targets:
); );
}); });
test("defaults.http.method 触发未知字段错误", async () => { test("defaults 顶层字段触发未知字段错误", async () => {
await expectConfigError( await expectConfigError(
"unknown-default-method.yaml", "unknown-defaults.yaml",
`defaults: `defaults:
http: http:
method: POST method: POST
@@ -1401,7 +1383,7 @@ targets:
http: http:
url: "http://example.com" url: "http://example.com"
`, `,
"defaults.http.method 是未知字段", "defaults 是未知字段",
); );
}); });
@@ -1428,11 +1410,7 @@ targets:
const configPath = join(tempDir, "dynamic-maps.yaml"); const configPath = join(tempDir, "dynamic-maps.yaml");
await writeFile( await writeFile(
configPath, configPath,
`defaults: `targets:
http:
headers:
X-Default-Header: "default"
targets:
- name: "http-test" - name: "http-test"
id: "http-test" id: "http-test"
type: http type: http
@@ -1458,7 +1436,6 @@ targets:
const cmdTarget = config.targets[1] as ResolvedCommandTarget; const cmdTarget = config.targets[1] as ResolvedCommandTarget;
expect(http.type).toBe("http"); expect(http.type).toBe("http");
expect(cmdTarget.type).toBe("cmd"); expect(cmdTarget.type).toBe("cmd");
expect(http.http.headers["X-Default-Header"]).toBe("default");
expect(http.http.headers["X-Custom-Header"]).toBe("custom"); expect(http.http.headers["X-Custom-Header"]).toBe("custom");
expect(cmdTarget.cmd.env["CUSTOM_ENV_NAME"]).toBe("custom"); expect(cmdTarget.cmd.env["CUSTOM_ENV_NAME"]).toBe("custom");
}); });
@@ -1924,15 +1901,11 @@ targets:
); );
}); });
test("tcp defaults 覆盖 banner 参数", async () => { test("tcp per-target banner 参数覆盖", async () => {
const configPath = join(tempDir, "tcp-defaults.yaml"); const configPath = join(tempDir, "tcp-defaults.yaml");
await writeFile( await writeFile(
configPath, configPath,
`defaults: `targets:
tcp:
bannerReadTimeout: 1000
maxBannerBytes: "8KB"
targets:
- id: "t1" - id: "t1"
type: tcp type: tcp
tcp: tcp:
@@ -1949,12 +1922,12 @@ targets:
const config = await loadConfig(configPath); const config = await loadConfig(configPath);
const t1 = config.targets[0]! as ResolvedTcpTarget; const t1 = config.targets[0]! as ResolvedTcpTarget;
expect(t1.tcp.bannerReadTimeout).toBe(1000); expect(t1.tcp.bannerReadTimeout).toBe(2000);
expect(t1.tcp.maxBannerBytes).toBe(8192); expect(t1.tcp.maxBannerBytes).toBe(4096);
const t2 = config.targets[1]! as ResolvedTcpTarget; const t2 = config.targets[1]! as ResolvedTcpTarget;
expect(t2.tcp.bannerReadTimeout).toBe(3000); expect(t2.tcp.bannerReadTimeout).toBe(3000);
expect(t2.tcp.maxBannerBytes).toBe(8192); expect(t2.tcp.maxBannerBytes).toBe(4096);
}); });
test("tcp expect 未知字段抛出错误", async () => { test("tcp expect 未知字段抛出错误", async () => {

View File

@@ -18,7 +18,7 @@ function makeCtx(timeoutMs = 5000): CheckerContext {
} }
function makeResolveContext(): ResolveContext { function makeResolveContext(): ResolveContext {
return { configDir: process.cwd(), defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 }; return { configDir: process.cwd(), defaultIntervalMs: 30000, defaultTimeoutMs: 10000 };
} }
function makeTarget( function makeTarget(

View File

@@ -4,13 +4,12 @@ import { validateDbConfig } from "../../../../../src/server/checker/runner/db/va
describe("validateDbConfig", () => { describe("validateDbConfig", () => {
test("空配置无问题", () => { test("空配置无问题", () => {
const result = validateDbConfig({ defaults: {}, targets: [] }); const result = validateDbConfig({ targets: [] });
expect(result).toHaveLength(0); expect(result).toHaveLength(0);
}); });
test("缺少 db.url 返回错误", () => { test("缺少 db.url 返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {},
targets: [{ id: "test", name: "test", type: "db" }], targets: [{ id: "test", name: "test", type: "db" }],
}); });
expect(result.length).toBeGreaterThan(0); expect(result.length).toBeGreaterThan(0);
@@ -21,7 +20,6 @@ describe("validateDbConfig", () => {
test("db.url 为空字符串返回错误", () => { test("db.url 为空字符串返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {},
targets: [{ db: { url: "" }, id: "test", name: "test", type: "db" }], targets: [{ db: { url: "" }, id: "test", name: "test", type: "db" }],
}); });
const urlError = result.find((e) => e.path.includes("db.url")); const urlError = result.find((e) => e.path.includes("db.url"));
@@ -31,7 +29,6 @@ describe("validateDbConfig", () => {
test("db.query 为空字符串返回错误", () => { test("db.query 为空字符串返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {},
targets: [{ db: { query: "", url: "sqlite://:memory:" }, id: "test", name: "test", type: "db" }], targets: [{ db: { query: "", url: "sqlite://:memory:" }, id: "test", name: "test", type: "db" }],
}); });
const queryError = result.find((e) => e.path.includes("db.query")); const queryError = result.find((e) => e.path.includes("db.query"));
@@ -41,7 +38,6 @@ describe("validateDbConfig", () => {
test("db 分组未知字段返回错误", () => { test("db 分组未知字段返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {},
targets: [{ db: { timeout: 5, url: "sqlite://:memory:" }, id: "test", name: "test", type: "db" }], targets: [{ db: { timeout: 5, url: "sqlite://:memory:" }, id: "test", name: "test", type: "db" }],
}); });
const unknownError = result.find((e) => e.path.includes("db.timeout")); const unknownError = result.find((e) => e.path.includes("db.timeout"));
@@ -51,7 +47,6 @@ describe("validateDbConfig", () => {
test("expect.durationMs 数组简写返回错误", () => { test("expect.durationMs 数组简写返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {},
targets: [ targets: [
{ {
db: { url: "sqlite://:memory:" }, db: { url: "sqlite://:memory:" },
@@ -69,7 +64,6 @@ describe("validateDbConfig", () => {
test("expect.rowCount 非法 operator 返回错误", () => { test("expect.rowCount 非法 operator 返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {},
targets: [ targets: [
{ db: { url: "sqlite://:memory:" }, expect: { rowCount: { foo: 1 } }, id: "test", name: "test", type: "db" }, { db: { url: "sqlite://:memory:" }, expect: { rowCount: { foo: 1 } }, id: "test", name: "test", type: "db" },
], ],
@@ -81,7 +75,6 @@ describe("validateDbConfig", () => {
test("expect.rows 不是数组返回错误", () => { test("expect.rows 不是数组返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {},
targets: [ targets: [
{ db: { url: "sqlite://:memory:" }, expect: { rows: "not-array" }, id: "test", name: "test", type: "db" }, { db: { url: "sqlite://:memory:" }, expect: { rows: "not-array" }, id: "test", name: "test", type: "db" },
], ],
@@ -93,7 +86,6 @@ describe("validateDbConfig", () => {
test("expect.rows 元素不是对象返回错误", () => { test("expect.rows 元素不是对象返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {},
targets: [ targets: [
{ db: { url: "sqlite://:memory:" }, expect: { rows: ["not-object"] }, id: "test", name: "test", type: "db" }, { db: { url: "sqlite://:memory:" }, expect: { rows: ["not-object"] }, id: "test", name: "test", type: "db" },
], ],
@@ -105,7 +97,6 @@ describe("validateDbConfig", () => {
test("expect.rows 中 regex 正则非法返回错误", () => { test("expect.rows 中 regex 正则非法返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {},
targets: [ targets: [
{ {
db: { url: "sqlite://:memory:" }, db: { url: "sqlite://:memory:" },
@@ -123,7 +114,6 @@ describe("validateDbConfig", () => {
test("expect 未知字段返回错误", () => { test("expect 未知字段返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {},
targets: [{ db: { url: "sqlite://:memory:" }, expect: { status: [200] }, id: "test", name: "test", type: "db" }], targets: [{ db: { url: "sqlite://:memory:" }, expect: { status: [200] }, id: "test", name: "test", type: "db" }],
}); });
const unknownError = result.find((e) => e.path.includes("expect.status")); const unknownError = result.find((e) => e.path.includes("expect.status"));
@@ -133,7 +123,6 @@ describe("validateDbConfig", () => {
test("有效配置无错误", () => { test("有效配置无错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {},
targets: [ targets: [
{ {
db: { query: "SELECT 1", url: "sqlite://:memory:" }, db: { query: "SELECT 1", url: "sqlite://:memory:" },
@@ -149,7 +138,6 @@ describe("validateDbConfig", () => {
test("忽略非 db 类型 target", () => { test("忽略非 db 类型 target", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {},
targets: [{ id: "test", name: "test", type: "http" }], targets: [{ id: "test", name: "test", type: "http" }],
}); });
expect(result).toHaveLength(0); expect(result).toHaveLength(0);
@@ -157,7 +145,6 @@ describe("validateDbConfig", () => {
test("多个 db target 分别校验", () => { test("多个 db target 分别校验", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {},
targets: [ targets: [
{ db: { url: "sqlite://:memory:" }, id: "db1", name: "db1", type: "db" }, { db: { url: "sqlite://:memory:" }, id: "db1", name: "db1", type: "db" },
{ db: { url: "" }, id: "db2", name: "db2", type: "db" }, { db: { url: "" }, id: "db2", name: "db2", type: "db" },

View File

@@ -19,7 +19,7 @@ const SLOW_BODY_DELAY_MS = 1000;
const FAST_RESPONSE_LIMIT_MS = 500; const FAST_RESPONSE_LIMIT_MS = 500;
function validateHttpTarget(target: unknown): string { function validateHttpTarget(target: unknown): string {
return formatConfigIssues(checker.validate({ defaults: {}, targets: [target as never] })); return formatConfigIssues(checker.validate({ targets: [target as never] }));
} }
const SELF_SIGNED_CERT = `-----BEGIN CERTIFICATE----- const SELF_SIGNED_CERT = `-----BEGIN CERTIFICATE-----
@@ -946,7 +946,6 @@ describe("HttpChecker.resolve", () => {
return { return {
configDir: ".", configDir: ".",
defaultIntervalMs: 30000, defaultIntervalMs: 30000,
defaults: {},
defaultTimeoutMs: 10000, defaultTimeoutMs: 10000,
}; };
} }

View File

@@ -124,7 +124,7 @@ describe("IcmpChecker resolve", () => {
test("解析默认值", () => { test("解析默认值", () => {
const target = checker.resolve( const target = checker.resolve(
{ icmp: { host: "10.0.0.1" }, id: "ping", type: "icmp" }, { icmp: { host: "10.0.0.1" }, id: "ping", type: "icmp" },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 }, { configDir: "/tmp", defaultIntervalMs: 30000, defaultTimeoutMs: 10000 },
); );
expect(target.icmp).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 }); expect(target.icmp).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 });
expect(target.group).toBe("default"); expect(target.group).toBe("default");

View File

@@ -5,7 +5,7 @@ import type { RawTargetConfig } from "../../../../../src/server/checker/types";
import { validatePingConfig } from "../../../../../src/server/checker/runner/icmp/validate"; import { validatePingConfig } from "../../../../../src/server/checker/runner/icmp/validate";
function validate(target: RawTargetConfig) { function validate(target: RawTargetConfig) {
return validatePingConfig({ defaults: {}, targets: [target] }); return validatePingConfig({ targets: [target] });
} }
describe("validatePingConfig", () => { describe("validatePingConfig", () => {

View File

@@ -17,14 +17,12 @@ describe("LLM registry integration", () => {
test("llm checker schemas 有效", () => { test("llm checker schemas 有效", () => {
const checker = checkerRegistry.get("llm"); const checker = checkerRegistry.get("llm");
expect(checker.schemas.config).toBeDefined(); expect(checker.schemas.config).toBeDefined();
expect(checker.schemas.defaults).toBeDefined();
expect(checker.schemas.expect).toBeDefined(); expect(checker.schemas.expect).toBeDefined();
}); });
test("llm checker validate 方法可用", () => { test("llm checker validate 方法可用", () => {
const checker = checkerRegistry.get("llm"); const checker = checkerRegistry.get("llm");
const issues = checker.validate({ const issues = checker.validate({
defaults: {},
targets: [], targets: [],
}); });
expect(issues).toHaveLength(0); expect(issues).toHaveLength(0);

View File

@@ -42,7 +42,6 @@ function makeResolveContext(overrides?: Partial<ResolveContext>): ResolveContext
return { return {
configDir: "/tmp", configDir: "/tmp",
defaultIntervalMs: 30000, defaultIntervalMs: 30000,
defaults: {},
defaultTimeoutMs: 10000, defaultTimeoutMs: 10000,
...overrides, ...overrides,
}; };
@@ -61,16 +60,15 @@ describe("LlmChecker schema", () => {
expect(checker?.configKey).toBe("llm"); expect(checker?.configKey).toBe("llm");
}); });
test("schemas 包含 config、defaults、expect", () => { test("schemas 包含 config、expect", () => {
expect(checker).toBeDefined(); expect(checker).toBeDefined();
expect(Object.keys(checker!.schemas).sort()).toEqual(["config", "defaults", "expect"].sort()); expect(Object.keys(checker!.schemas).sort()).toEqual(["config", "expect"].sort());
}); });
}); });
describe("LlmChecker validate", () => { describe("LlmChecker validate", () => {
test("合法 LLM target 无校验问题", () => { test("合法 LLM target 无校验问题", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [makeRawTarget()], targets: [makeRawTarget()],
}); });
expect(issues).toHaveLength(0); expect(issues).toHaveLength(0);
@@ -78,7 +76,6 @@ describe("LlmChecker validate", () => {
test("provider 非法报错", () => { test("provider 非法报错", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [ targets: [
makeRawTarget({ makeRawTarget({
llm: { model: "m", prompt: "p", provider: "gemini", url: "https://x" }, llm: { model: "m", prompt: "p", provider: "gemini", url: "https://x" },
@@ -91,7 +88,6 @@ describe("LlmChecker validate", () => {
test("url 非法报错", () => { test("url 非法报错", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [ targets: [
makeRawTarget({ makeRawTarget({
llm: { model: "m", prompt: "p", provider: "openai", url: "ftp://bad" }, llm: { model: "m", prompt: "p", provider: "openai", url: "ftp://bad" },
@@ -104,7 +100,6 @@ describe("LlmChecker validate", () => {
test("model 为空报错", () => { test("model 为空报错", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [ targets: [
makeRawTarget({ makeRawTarget({
llm: { model: "", prompt: "p", provider: "openai", url: "https://x" }, llm: { model: "", prompt: "p", provider: "openai", url: "https://x" },
@@ -117,7 +112,6 @@ describe("LlmChecker validate", () => {
test("prompt 为空报错", () => { test("prompt 为空报错", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [ targets: [
makeRawTarget({ makeRawTarget({
llm: { model: "m", prompt: "", provider: "openai", url: "https://x" }, llm: { model: "m", prompt: "", provider: "openai", url: "https://x" },
@@ -130,7 +124,6 @@ describe("LlmChecker validate", () => {
test("mode 非法报错", () => { test("mode 非法报错", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [ targets: [
makeRawTarget({ makeRawTarget({
llm: { mode: "batch", model: "m", prompt: "p", provider: "openai", url: "https://x" }, llm: { mode: "batch", model: "m", prompt: "p", provider: "openai", url: "https://x" },
@@ -143,7 +136,6 @@ describe("LlmChecker validate", () => {
test("openai provider 不允许 authToken", () => { test("openai provider 不允许 authToken", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [ targets: [
makeRawTarget({ makeRawTarget({
llm: { authToken: "tok", model: "m", prompt: "p", provider: "openai", url: "https://x" }, llm: { authToken: "tok", model: "m", prompt: "p", provider: "openai", url: "https://x" },
@@ -155,7 +147,6 @@ describe("LlmChecker validate", () => {
test("anthropic 同时配置 key 和 authToken 报错", () => { test("anthropic 同时配置 key 和 authToken 报错", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [ targets: [
makeRawTarget({ makeRawTarget({
llm: { authToken: "tok", key: "k", model: "m", prompt: "p", provider: "anthropic", url: "https://x" }, llm: { authToken: "tok", key: "k", model: "m", prompt: "p", provider: "anthropic", url: "https://x" },
@@ -167,7 +158,6 @@ describe("LlmChecker validate", () => {
test("ignoreSSL 非布尔值报错", () => { test("ignoreSSL 非布尔值报错", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [ targets: [
makeRawTarget({ makeRawTarget({
llm: { ignoreSSL: "yes", model: "m", prompt: "p", provider: "openai", url: "https://x" }, llm: { ignoreSSL: "yes", model: "m", prompt: "p", provider: "openai", url: "https://x" },
@@ -179,7 +169,6 @@ describe("LlmChecker validate", () => {
test("options.maxOutputTokens 非正整数报错", () => { test("options.maxOutputTokens 非正整数报错", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [ targets: [
makeRawTarget({ makeRawTarget({
llm: { model: "m", options: { maxOutputTokens: -1 }, prompt: "p", provider: "openai", url: "https://x" }, llm: { model: "m", options: { maxOutputTokens: -1 }, prompt: "p", provider: "openai", url: "https://x" },
@@ -191,7 +180,6 @@ describe("LlmChecker validate", () => {
test("options.stopSequences 非字符串数组报错", () => { test("options.stopSequences 非字符串数组报错", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [ targets: [
makeRawTarget({ makeRawTarget({
llm: { model: "m", options: { stopSequences: [123] }, prompt: "p", provider: "openai", url: "https://x" }, llm: { model: "m", options: { stopSequences: [123] }, prompt: "p", provider: "openai", url: "https://x" },
@@ -203,7 +191,6 @@ describe("LlmChecker validate", () => {
test("expect.output 缺少规则类型报错", () => { test("expect.output 缺少规则类型报错", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [makeRawTarget({ expect: { output: [{}] } })], targets: [makeRawTarget({ expect: { output: [{}] } })],
}); });
expect(issues.some((i) => i.code === "empty-matcher")).toBe(true); expect(issues.some((i) => i.code === "empty-matcher")).toBe(true);
@@ -211,7 +198,6 @@ describe("LlmChecker validate", () => {
test("expect.output 直接 matcher 混入 extractor 报错", () => { test("expect.output 直接 matcher 混入 extractor 报错", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [makeRawTarget({ expect: { output: [{ contains: "y", json: { path: "$.status" } }] } })], targets: [makeRawTarget({ expect: { output: [{ contains: "y", json: { path: "$.status" } }] } })],
}); });
expect(issues.some((i) => i.code === "invalid-content-expectation")).toBe(true); expect(issues.some((i) => i.code === "invalid-content-expectation")).toBe(true);
@@ -219,7 +205,6 @@ describe("LlmChecker validate", () => {
test("expect.output regex ReDoS 报错", () => { test("expect.output regex ReDoS 报错", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [makeRawTarget({ expect: { output: [{ regex: "(a+)+" }] } })], targets: [makeRawTarget({ expect: { output: [{ regex: "(a+)+" }] } })],
}); });
expect(issues.some((i) => i.code === "unsafe-regex")).toBe(true); expect(issues.some((i) => i.code === "unsafe-regex")).toBe(true);
@@ -227,7 +212,6 @@ describe("LlmChecker validate", () => {
test("expect.stream 在 mode:http 下报错", () => { test("expect.stream 在 mode:http 下报错", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [ targets: [
makeRawTarget({ makeRawTarget({
expect: { stream: { completed: true } }, expect: { stream: { completed: true } },
@@ -240,7 +224,6 @@ describe("LlmChecker validate", () => {
test("expect.stream 在 mode:stream 下合法", () => { test("expect.stream 在 mode:stream 下合法", () => {
const issues = validateLlmConfig({ const issues = validateLlmConfig({
defaults: {},
targets: [ targets: [
makeRawTarget({ makeRawTarget({
expect: { stream: { completed: true } }, expect: { stream: { completed: true } },
@@ -250,24 +233,6 @@ describe("LlmChecker validate", () => {
}); });
expect(issues).toHaveLength(0); expect(issues).toHaveLength(0);
}); });
test("defaults.llm 合法配置", () => {
const issues = validateLlmConfig({
defaults: {
llm: { headers: { "X-Custom": "val" }, ignoreSSL: false, mode: "http", options: { maxOutputTokens: 32 } },
},
targets: [makeRawTarget()],
});
expect(issues).toHaveLength(0);
});
test("defaults.llm mode 非法报错", () => {
const issues = validateLlmConfig({
defaults: { llm: { mode: "batch" } },
targets: [makeRawTarget()],
});
expect(issues.some((i) => i.path.includes("defaults.llm.mode"))).toBe(true);
});
}); });
describe("LlmChecker resolve", () => { describe("LlmChecker resolve", () => {
@@ -324,61 +289,6 @@ describe("LlmChecker resolve", () => {
expect(resolved.expect?.stream).toEqual({ completed: true, firstTokenMs: { equals: 100 } }); expect(resolved.expect?.stream).toEqual({ completed: true, firstTokenMs: { equals: 100 } });
}); });
test("defaults.llm 与 target.llm 浅合并", () => {
const raw = makeRawTarget({
llm: {
headers: { Authorization: "Bearer test" },
model: "gpt-4o-mini",
prompt: "Say OK",
provider: "openai",
url: "https://api.openai.com/v1",
},
});
const ctx = makeResolveContext({
defaults: {
llm: {
headers: { "X-Custom": "default" },
ignoreSSL: true,
mode: "stream",
options: { maxOutputTokens: 64, temperature: 0.5 },
},
},
});
const resolved = asLlm(checker.resolve(raw, ctx));
expect(resolved.llm.mode).toBe("stream");
expect(resolved.llm.ignoreSSL).toBe(true);
expect(resolved.llm.headers).toEqual({ Authorization: "Bearer test", "X-Custom": "default" });
expect(resolved.llm.options.maxOutputTokens).toBe(64);
expect(resolved.llm.options.temperature).toBe(0.5);
});
test("target 字段覆盖 defaults", () => {
const raw = makeRawTarget({
llm: {
ignoreSSL: false,
mode: "http",
model: "gpt-4o-mini",
options: { maxOutputTokens: 8 },
prompt: "Say OK",
provider: "openai",
url: "https://api.openai.com/v1",
},
});
const ctx = makeResolveContext({
defaults: {
llm: {
ignoreSSL: true,
mode: "stream",
options: { maxOutputTokens: 64 },
},
},
});
const resolved = asLlm(checker.resolve(raw, ctx));
expect(resolved.llm.mode).toBe("http");
expect(resolved.llm.ignoreSSL).toBe(false);
expect(resolved.llm.options.maxOutputTokens).toBe(8);
});
test("serialize 返回正确格式", () => { test("serialize 返回正确格式", () => {
const resolved = asLlm(checker.resolve(makeRawTarget(), makeResolveContext())); const resolved = asLlm(checker.resolve(makeRawTarget(), makeResolveContext()));
const serialized = checker.serialize(resolved); const serialized = checker.serialize(resolved);
@@ -398,25 +308,4 @@ describe("LlmChecker resolve", () => {
const config = parseSerializedConfig(serialized.config); const config = parseSerializedConfig(serialized.config);
expect(config.key).toBe("***"); expect(config.key).toBe("***");
}); });
test("providerOptions 浅合并", () => {
const raw = makeRawTarget({
llm: {
model: "m",
prompt: "p",
provider: "openai",
providerOptions: { openai: { store: true } },
url: "https://x",
},
});
const ctx = makeResolveContext({
defaults: {
llm: {
providerOptions: { openai: { user: "default-user" } },
},
},
});
const resolved = asLlm(checker.resolve(raw, ctx));
expect(resolved.llm.providerOptions).toEqual({ openai: { store: true } });
});
}); });

View File

@@ -15,7 +15,6 @@ function createChecker(type: string): Checker {
resolve: () => ({}) as unknown as ResolvedTargetBase, resolve: () => ({}) as unknown as ResolvedTargetBase,
schemas: { schemas: {
config: Type.Object({}, { additionalProperties: false }), config: Type.Object({}, { additionalProperties: false }),
defaults: Type.Object({}, { additionalProperties: false }),
expect: Type.Object({}, { additionalProperties: false }), expect: Type.Object({}, { additionalProperties: false }),
}, },
serialize: () => ({ config: "", target: "" }), serialize: () => ({ config: "", target: "" }),
@@ -69,11 +68,7 @@ describe("CheckerRegistry", () => {
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "custom"]); expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm"]); expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm"]);
expect( expect(first.definitions.every((checker) => checker.schemas.config && checker.schemas.expect)).toBe(true);
first.definitions.every(
(checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect,
),
).toBe(true);
}); });
test("默认 registry 注册 icmp type", () => { test("默认 registry 注册 icmp type", () => {

View File

@@ -7,7 +7,7 @@ import { validateHttpConfig } from "../../../../../src/server/checker/runner/htt
import { validateLlmConfig } from "../../../../../src/server/checker/runner/llm/validate"; import { validateLlmConfig } from "../../../../../src/server/checker/runner/llm/validate";
function input(target: Record<string, unknown>): CheckerValidationInput { function input(target: Record<string, unknown>): CheckerValidationInput {
return { defaults: {}, targets: [target as CheckerValidationInput["targets"][number]] }; return { targets: [target as CheckerValidationInput["targets"][number]] };
} }
describe("HTTP/LLM headers reject case-insensitive duplicate keys", () => { describe("HTTP/LLM headers reject case-insensitive duplicate keys", () => {

View File

@@ -11,7 +11,7 @@ import { validateTcpConfig } from "../../../../../src/server/checker/runner/tcp/
import { validateUdpConfig } from "../../../../../src/server/checker/runner/udp/validate"; import { validateUdpConfig } from "../../../../../src/server/checker/runner/udp/validate";
function input(target: Record<string, unknown>): CheckerValidationInput { function input(target: Record<string, unknown>): CheckerValidationInput {
return { defaults: {}, targets: [target as CheckerValidationInput["targets"][number]] }; return { targets: [target as CheckerValidationInput["targets"][number]] };
} }
describe("ValueMatcher primitive shorthand in checker validators", () => { describe("ValueMatcher primitive shorthand in checker validators", () => {

View File

@@ -303,7 +303,7 @@ describe("TcpChecker resolve", () => {
test("最简 tcp 配置解析默认值", () => { test("最简 tcp 配置解析默认值", () => {
const target = checker.resolve( const target = checker.resolve(
{ id: "t", tcp: { host: "127.0.0.1", port: 6379 }, type: "tcp" }, { id: "t", tcp: { host: "127.0.0.1", port: 6379 }, type: "tcp" },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 }, { configDir: "/tmp", defaultIntervalMs: 30000, defaultTimeoutMs: 10000 },
); );
expect(target.tcp.host).toBe("127.0.0.1"); expect(target.tcp.host).toBe("127.0.0.1");
expect(target.tcp.port).toBe(6379); expect(target.tcp.port).toBe(6379);
@@ -325,44 +325,17 @@ describe("TcpChecker resolve", () => {
tcp: { bannerReadTimeout: 5000, host: "127.0.0.1", maxBannerBytes: "1KB", port: 80, readBanner: true }, tcp: { bannerReadTimeout: 5000, host: "127.0.0.1", maxBannerBytes: "1KB", port: 80, readBanner: true },
type: "tcp", type: "tcp",
}, },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 }, { configDir: "/tmp", defaultIntervalMs: 30000, defaultTimeoutMs: 10000 },
); );
expect(target.tcp.bannerReadTimeout).toBe(5000); expect(target.tcp.bannerReadTimeout).toBe(5000);
expect(target.tcp.maxBannerBytes).toBe(1024); expect(target.tcp.maxBannerBytes).toBe(1024);
expect(target.tcp.readBanner).toBe(true); expect(target.tcp.readBanner).toBe(true);
}); });
test("defaults.tcp 合并到 target", () => {
const target = checker.resolve(
{ id: "t", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" },
{
configDir: "/tmp",
defaultIntervalMs: 30000,
defaults: { tcp: { bannerReadTimeout: 1000, maxBannerBytes: "8KB" } },
defaultTimeoutMs: 10000,
},
);
expect(target.tcp.bannerReadTimeout).toBe(1000);
expect(target.tcp.maxBannerBytes).toBe(8192);
});
test("per-target 覆盖 defaults.tcp", () => {
const target = checker.resolve(
{ id: "t", tcp: { bannerReadTimeout: 5000, host: "127.0.0.1", port: 80 }, type: "tcp" },
{
configDir: "/tmp",
defaultIntervalMs: 30000,
defaults: { tcp: { bannerReadTimeout: 1000 } },
defaultTimeoutMs: 10000,
},
);
expect(target.tcp.bannerReadTimeout).toBe(5000);
});
test("maxBannerBytes 整数默认值解析", () => { test("maxBannerBytes 整数默认值解析", () => {
const target = checker.resolve( const target = checker.resolve(
{ id: "t", tcp: { host: "127.0.0.1", maxBannerBytes: 2048, port: 80 }, type: "tcp" }, { id: "t", tcp: { host: "127.0.0.1", maxBannerBytes: 2048, port: 80 }, type: "tcp" },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 }, { configDir: "/tmp", defaultIntervalMs: 30000, defaultTimeoutMs: 10000 },
); );
expect(target.tcp.maxBannerBytes).toBe(2048); expect(target.tcp.maxBannerBytes).toBe(2048);
}); });
@@ -375,7 +348,7 @@ describe("TcpChecker resolve", () => {
tcp: { host: "127.0.0.1", port: 80, readBanner: true }, tcp: { host: "127.0.0.1", port: 80, readBanner: true },
type: "tcp", type: "tcp",
}, },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 }, { configDir: "/tmp", defaultIntervalMs: 30000, defaultTimeoutMs: 10000 },
); );
expect(target.expect).toEqual({ expect(target.expect).toEqual({
banner: [{ kind: "value", matcher: { contains: "ESMTP" } }], banner: [{ kind: "value", matcher: { contains: "ESMTP" } }],
@@ -388,7 +361,7 @@ describe("TcpChecker resolve", () => {
test("name 和 group 解析", () => { test("name 和 group 解析", () => {
const target = checker.resolve( const target = checker.resolve(
{ group: "infra", id: "t", name: "redis", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }, { group: "infra", id: "t", name: "redis", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 }, { configDir: "/tmp", defaultIntervalMs: 30000, defaultTimeoutMs: 10000 },
); );
expect(target.name).toBe("redis"); expect(target.name).toBe("redis");
expect(target.group).toBe("infra"); expect(target.group).toBe("infra");

View File

@@ -4,9 +4,8 @@ import type { CheckerValidationInput } from "../../../../../src/server/checker/r
import { validateTcpConfig } from "../../../../../src/server/checker/runner/tcp/validate"; import { validateTcpConfig } from "../../../../../src/server/checker/runner/tcp/validate";
function makeInput(targets: unknown[], defaults?: Record<string, unknown>): CheckerValidationInput { function makeInput(targets: unknown[]): CheckerValidationInput {
return { return {
defaults: defaults ?? {},
targets: targets as CheckerValidationInput["targets"], targets: targets as CheckerValidationInput["targets"],
}; };
} }
@@ -152,40 +151,4 @@ describe("validateTcpConfig", () => {
const issues = validateTcpConfig(makeInput([{ http: { url: "http://example.com" }, id: "t1", type: "http" }])); const issues = validateTcpConfig(makeInput([{ http: { url: "http://example.com" }, id: "t1", type: "http" }]));
expect(issues).toHaveLength(0); expect(issues).toHaveLength(0);
}); });
test("defaults.tcp 合法字段无错误", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
tcp: { bannerReadTimeout: 1000, maxBannerBytes: "8KB" },
}),
);
expect(issues).toHaveLength(0);
});
test("defaults.tcp 未知字段", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
tcp: { bannerReadTimeout: 1000, host: "127.0.0.1" },
}),
);
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
});
test("defaults.tcp bannerReadTimeout 非法", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
tcp: { bannerReadTimeout: "slow" },
}),
);
expect(issues.some((i) => i.path.includes("bannerReadTimeout"))).toBe(true);
});
test("defaults.tcp maxBannerBytes 非法", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
tcp: { maxBannerBytes: true },
}),
);
expect(issues.some((i) => i.path.includes("maxBannerBytes"))).toBe(true);
});
}); });

View File

@@ -334,7 +334,7 @@ describe("UdpChecker resolve", () => {
const checker = new UdpChecker(); const checker = new UdpChecker();
const target = checker.resolve( const target = checker.resolve(
{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 9000 } }, { id: "test", type: "udp", udp: { host: "127.0.0.1", port: 9000 } },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 }, { configDir: "/tmp", defaultIntervalMs: 30000, defaultTimeoutMs: 10000 },
); );
expect(target.udp.payload).toBe(""); expect(target.udp.payload).toBe("");
expect(target.udp.encoding).toBe("text"); expect(target.udp.encoding).toBe("text");
@@ -344,32 +344,11 @@ describe("UdpChecker resolve", () => {
expect(target.expect).toEqual({ responded: true }); expect(target.expect).toEqual({ responded: true });
}); });
it("should use defaults.udp for missing fields", () => {
const checker = new UdpChecker();
const target = checker.resolve(
{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 9000 } },
{
configDir: "/tmp",
defaultIntervalMs: 30000,
defaults: { udp: { encoding: "hex", maxResponseBytes: "8KB", responseEncoding: "hex" } },
defaultTimeoutMs: 10000,
},
);
expect(target.udp.encoding).toBe("hex");
expect(target.udp.responseEncoding).toBe("hex");
expect(target.udp.maxResponseBytes).toBe(8192);
});
it("should override defaults with target-level config", () => { it("should override defaults with target-level config", () => {
const checker = new UdpChecker(); const checker = new UdpChecker();
const target = checker.resolve( const target = checker.resolve(
{ id: "test", type: "udp", udp: { encoding: "base64", host: "127.0.0.1", port: 9000 } }, { id: "test", type: "udp", udp: { encoding: "base64", host: "127.0.0.1", port: 9000 } },
{ { configDir: "/tmp", defaultIntervalMs: 30000, defaultTimeoutMs: 10000 },
configDir: "/tmp",
defaultIntervalMs: 30000,
defaults: { udp: { encoding: "hex" } },
defaultTimeoutMs: 10000,
},
); );
expect(target.udp.encoding).toBe("base64"); expect(target.udp.encoding).toBe("base64");
}); });

View File

@@ -5,11 +5,7 @@ import type { CheckerValidationInput } from "../../../../../src/server/checker/r
import { validateUdpConfig } from "../../../../../src/server/checker/runner/udp/validate"; import { validateUdpConfig } from "../../../../../src/server/checker/runner/udp/validate";
describe("validateUdpConfig", () => { describe("validateUdpConfig", () => {
const makeInput = (overrides: { const makeInput = (overrides: { targets?: Array<Record<string, unknown>> }): CheckerValidationInput => ({
defaults?: Record<string, unknown>;
targets?: Array<Record<string, unknown>>;
}): CheckerValidationInput => ({
defaults: overrides.defaults ?? {},
targets: (overrides.targets ?? []) as CheckerValidationInput["targets"], targets: (overrides.targets ?? []) as CheckerValidationInput["targets"],
}); });
@@ -67,29 +63,6 @@ describe("validateUdpConfig", () => {
expect(issues[0]!.path).toContain("udp"); expect(issues[0]!.path).toContain("udp");
}); });
it("accepts valid defaults.udp with encoding, responseEncoding, maxResponseBytes", () => {
const issues = validateUdpConfig(
makeInput({
defaults: {
udp: { encoding: "hex", maxResponseBytes: 1024, responseEncoding: "text" },
},
targets: [{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 53 } }],
}),
);
expect(issues).toHaveLength(0);
});
it("reports unknown-field in defaults.udp", () => {
const issues = validateUdpConfig(
makeInput({
defaults: { udp: { unknownField: true } },
targets: [{ id: "test", type: "udp", udp: { host: "127.0.0.1", port: 53 } }],
}),
);
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe("unknown-field");
});
it("reports invalid-value for udp.encoding with bad value", () => { it("reports invalid-value for udp.encoding with bad value", () => {
const issues = validateUdpConfig( const issues = validateUdpConfig(
makeInput({ makeInput({