feat: 扩展配置变量替换范围至 server/probes/targets,支持空默认值语法
This commit is contained in:
@@ -248,7 +248,7 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode:
|
||||
|
||||
配置加载流程固定为:`unknown -> 变量替换 -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`。
|
||||
|
||||
变量替换阶段由 `variables.ts` 负责,在 YAML 解析之后、AJV 契约校验之前执行。顶层 `variables` 支持 string/number/boolean 字面量,target 字符串字段支持 `${key}`、`${key|default}` 和 `$${key}`,解析优先级为 `variables -> process.env -> 默认值`;替换范围仅限 `targets`,且跳过 `id` 和 `type` 字段。
|
||||
变量替换阶段由 `variables.ts` 负责,在 YAML 解析之后、AJV 契约校验之前执行。顶层 `variables` 支持 string/number/boolean 字面量且自身不参与替换;`server`、`probes` 和 `targets` 字符串字段支持 `${key}`、`${key|default}`、`${key|}` 和 `$${key}`,解析优先级为 `variables -> process.env -> 默认值 -> unresolved-variable 报错`;替换范围不包含对象 key,且仅跳过 `targets[].id` 和 `targets[].type` 字段。
|
||||
|
||||
`config-loader.ts` 只负责 YAML 解析、契约调度、公共语义校验和最终运行期解析;checker 专属规则必须下沉到对应 checker 的 `schema.ts` 和 `validate.ts`。
|
||||
|
||||
|
||||
18
README.md
18
README.md
@@ -119,22 +119,25 @@ dist/release/
|
||||
server: # 服务配置(均可省略)
|
||||
listen:
|
||||
host: "127.0.0.1"
|
||||
port: 3000
|
||||
port: "${server_port}"
|
||||
storage:
|
||||
dataDir: "/tmp/probes_data"
|
||||
retention: "7d"
|
||||
retention: "${retention}"
|
||||
logging:
|
||||
level: "info"
|
||||
level: "${log_level|info}"
|
||||
file:
|
||||
path: "<dataDir>/logs/dial.log"
|
||||
|
||||
probes: # 拨测运行时配置(可省略)
|
||||
execution:
|
||||
maxConcurrentChecks: 20
|
||||
maxConcurrentChecks: "${max_checks}"
|
||||
|
||||
variables: # 配置变量(可省略)
|
||||
env_name: "生产"
|
||||
base_url: "https://api.example.com"
|
||||
server_port: 3000
|
||||
retention: "7d"
|
||||
max_checks: 20
|
||||
default_interval: "30s" # 通过变量在多个 target 间共享常用值
|
||||
default_timeout: "10s"
|
||||
|
||||
@@ -200,17 +203,18 @@ targets: # 拨测目标列表(必填)
|
||||
- `timeout`:`10s`(超时时间)
|
||||
- 各 checker 专属默认值见对应章节
|
||||
|
||||
如需在多个 target 间共享相同的配置值,可使用 `variables` 定义变量,然后在 target 中通过 `${var}` 引用。例如在 `variables` 中定义 `default_interval: "30s"`,在多个 target 的 `interval` 字段写 `${default_interval}`。
|
||||
如需在配置文件中共享相同的配置值,可使用 `variables` 定义变量,然后在 `server`、`probes` 和 `targets` 中通过 `${var}` 引用。例如在 `variables` 中定义 `default_interval: "30s"`,在多个 target 的 `interval` 字段写 `${default_interval}`。
|
||||
|
||||
### variables — 配置变量
|
||||
|
||||
`variables` 是顶层动态键值表,key 必须符合 `[a-zA-Z_][a-zA-Z0-9_]*`,value 仅支持 string、number、boolean。target 中的字符串值可引用变量:
|
||||
`variables` 是顶层动态键值表,key 必须符合 `[a-zA-Z_][a-zA-Z0-9_]*`,value 仅支持 string、number、boolean。`server`、`probes` 和 `targets` 中的字符串值可引用变量:
|
||||
|
||||
- `${key}`:引用 variables 或环境变量
|
||||
- `${key|default}`:变量和环境变量都不存在时使用默认值,第一个 `|` 后的内容为默认值
|
||||
- `${key|}`:变量和环境变量都不存在时使用空字符串作为默认值
|
||||
- `$${key}`:转义输出字面量 `${key}`
|
||||
|
||||
解析优先级为 `variables -> process.env -> 默认值`。字段值完整等于单个变量引用时会保留 number/boolean/string 类型;部分拼接时统一转为字符串。变量替换仅作用于 `targets`,且不会替换 `id` 和 `type` 字段。
|
||||
解析优先级为 `variables -> process.env -> 默认值`,三者均不存在时配置校验失败。字段值完整等于单个变量引用时会保留 number/boolean/string 类型,环境变量和默认值会做类型推断,但空字符串保持为字符串;部分拼接时统一转为字符串。变量替换作用于 `server`、`probes` 和 `targets`,不作用于 `variables` 段自身,且不会替换 `targets[].id` 和 `targets[].type` 字段;对象 key 不参与替换。
|
||||
|
||||
### targets — 拨测目标列表(必填)
|
||||
|
||||
|
||||
@@ -37,10 +37,10 @@
|
||||
|
||||
#### Scenario: 不定义 variables 段
|
||||
- **WHEN** 配置文件不包含 variables 段
|
||||
- **THEN** 系统 SHALL 正常启动,targets 中的 `${...}` 引用仅从环境变量查找
|
||||
- **THEN** 系统 SHALL 正常启动,支持变量替换的配置字段中的 `${...}` 引用仅从环境变量和表达式默认值查找
|
||||
|
||||
### Requirement: 变量引用语法
|
||||
targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHALL 支持 `${key|default}` 语法设置默认值,其中第一个 `|` 为分隔符,后续 `|` 属于默认值内容。系统 SHALL 支持 `$${...}` 转义语法输出字面量 `${...}`。
|
||||
支持变量替换的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHALL 支持 `${key|default}` 语法设置默认值,其中第一个 `|` 为分隔符,后续 `|` 属于默认值内容。系统 SHALL 支持 `${key|}` 显式设置空字符串默认值。系统 SHALL 支持 `$${...}` 转义语法输出字面量 `${...}`。
|
||||
|
||||
#### Scenario: 简单变量引用
|
||||
- **WHEN** target 字段值为 `"${base_url}/health"` 且 variables 中定义 `base_url: "https://api.example.com"`
|
||||
@@ -54,6 +54,10 @@ targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHA
|
||||
- **WHEN** target 字段值为 `"${PATTERN|foo|bar}"` 且变量不存在
|
||||
- **THEN** 系统 SHALL 使用 `"foo|bar"` 作为默认值(第一个 `|` 为分隔符)
|
||||
|
||||
#### Scenario: 空默认值
|
||||
- **WHEN** 支持变量替换的字段值为 `"${OPTIONAL_VALUE|}"` 且 variables 和环境变量中均不存在 OPTIONAL_VALUE
|
||||
- **THEN** 系统 SHALL 使用空字符串 `""` 作为默认值且不报 unresolved-variable 错误
|
||||
|
||||
#### Scenario: 转义语法
|
||||
- **WHEN** target 字段值为 `"Hello $${name}"`
|
||||
- **THEN** 系统 SHALL 输出 `"Hello ${name}"`,不进行变量替换
|
||||
@@ -82,11 +86,11 @@ targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHA
|
||||
- **THEN** 系统 SHALL 使用默认值(类型推断后为 number 60)
|
||||
|
||||
#### Scenario: 变量未定义且无默认值报错
|
||||
- **WHEN** target 字段引用 `${MISSING_VAR}` 且 variables、环境变量中均不存在,也未设置默认值
|
||||
- **WHEN** 支持变量替换的字段引用 `${MISSING_VAR}` 且 variables、环境变量中均不存在,也未设置默认值
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示该变量未定义
|
||||
|
||||
### Requirement: 完整引用类型保留
|
||||
当字段值仅包含单个变量引用(完整引用)时,系统 SHALL 保留变量的原始类型。完整引用的判定为:字段值去掉首尾空白后严格匹配单个 `${key}` 或 `${key|default}` 模式且无其他字符。
|
||||
当字段值仅包含单个变量引用(完整引用)时,系统 SHALL 保留变量的原始类型。完整引用的判定为:字段值去掉首尾空白后严格匹配单个 `${key}` 或 `${key|default}` 模式且无其他字符。环境变量和表达式默认值的完整引用 SHALL 做 number/boolean 类型推断,但空字符串 MUST 保持为 string `""`。
|
||||
|
||||
#### Scenario: 完整引用 number 变量
|
||||
- **WHEN** target 字段值为 `"${port}"` 且 variables 中 `port: 5432`
|
||||
@@ -120,8 +124,16 @@ targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHA
|
||||
- **WHEN** target 字段值为 `"${HOST|localhost}"` 且变量不存在
|
||||
- **THEN** 系统 SHALL 将该字段替换为 string 类型的 "localhost"
|
||||
|
||||
#### Scenario: 空默认值保持字符串
|
||||
- **WHEN** 支持变量替换的字段值为 `"${OPTIONAL_VALUE|}"` 且变量不存在
|
||||
- **THEN** 系统 SHALL 将该字段替换为 string 类型的空字符串 `""`,MUST NOT 推断为 number 0
|
||||
|
||||
#### Scenario: 空环境变量保持字符串
|
||||
- **WHEN** 支持变量替换的字段值为 `"${EMPTY_ENV}"` 且环境变量 `EMPTY_ENV` 存在但值为空字符串
|
||||
- **THEN** 系统 SHALL 将该字段替换为 string 类型的空字符串 `""`,MUST NOT 推断为 number 0
|
||||
|
||||
### Requirement: 替换范围限制
|
||||
变量替换 SHALL 仅作用于 targets 段。`id` 和 `type` 字段 MUST NOT 参与变量替换。`server`、`probes` 段 MUST NOT 参与变量替换。系统 SHALL 递归遍历 target 对象树中所有字符串值进行替换(包括嵌套对象和数组元素中的字符串)。顶层 `defaults` 不再是合法配置段,因此不属于变量替换范围。
|
||||
变量替换 SHALL 作用于 `server`、`probes` 和 `targets` 段中的字符串值。`variables` 段自身 MUST NOT 参与变量替换。系统 SHALL 递归遍历支持范围内对象树中所有字符串 value 进行替换,包括嵌套对象和数组元素中的字符串。系统 MUST NOT 替换对象 key。`targets[].id` 和 `targets[].type` 字段 MUST NOT 参与变量替换;target 内部其他路径上名为 `id` 或 `type` 的字段 SHALL 正常参与变量替换。顶层 `defaults` 不再是合法配置段,因此不属于变量替换范围。
|
||||
|
||||
#### Scenario: target 嵌套对象中的变量替换
|
||||
- **WHEN** target 配置 `http.headers.Authorization: "${token}"` 且 variables 中定义 `token: "Bearer abc"`
|
||||
@@ -131,44 +143,76 @@ targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHA
|
||||
- **WHEN** target 配置 `cmd.args: ["--host", "${host}"]` 且 variables 中定义 `host: "localhost"`
|
||||
- **THEN** 系统 SHALL 将数组第二个元素替换为 "localhost"
|
||||
|
||||
#### Scenario: id 字段不替换
|
||||
#### Scenario: server 段变量替换
|
||||
- **WHEN** server 配置 `listen.host: "${server_host}"` 且 variables 中定义 `server_host: "0.0.0.0"`
|
||||
- **THEN** 系统 SHALL 将 server.listen.host 替换为 "0.0.0.0"
|
||||
|
||||
#### Scenario: probes 段变量替换
|
||||
- **WHEN** probes 配置 `execution.maxConcurrentChecks: "${max_checks}"` 且 variables 中定义 `max_checks: 5`
|
||||
- **THEN** 系统 SHALL 将 probes.execution.maxConcurrentChecks 替换为 number 5
|
||||
|
||||
#### Scenario: variables 段不替换
|
||||
- **WHEN** variables 配置 `host: "${HOST}"` 且环境变量 HOST 存在
|
||||
- **THEN** 系统 SHALL 保持 variables.host 为字面量 `"${HOST}"`,不进行替换
|
||||
|
||||
#### Scenario: target id 字段不替换
|
||||
- **WHEN** target 配置 `id: "${my_id}"` 且 variables 中定义 `my_id: "test"`
|
||||
- **THEN** 系统 SHALL 保持 id 字段值为字面量 `"${my_id}"`,不进行替换
|
||||
|
||||
#### Scenario: type 字段不替换
|
||||
#### Scenario: target type 字段不替换
|
||||
- **WHEN** target 配置 `type: "${checker_type}"` 且 variables 中定义 `checker_type: "http"`
|
||||
- **THEN** 系统 SHALL 保持 type 字段值为字面量 `"${checker_type}"`,不进行替换
|
||||
|
||||
#### Scenario: server 段不替换
|
||||
- **WHEN** server 配置 `listen.host: "${server_host}"` 且 variables 中定义 `server_host: "0.0.0.0"`
|
||||
- **THEN** 系统 SHALL 保持 server.listen.host 为字面量 `"${server_host}"`,不进行替换
|
||||
#### Scenario: target 嵌套 id 字段参与替换
|
||||
- **WHEN** target 内部非顶层身份字段配置 `expect.rows[0].id.equals: "${expected_id}"` 且 variables 中定义 `expected_id: 1`
|
||||
- **THEN** 系统 SHALL 将该嵌套 id 字段替换为 number 1
|
||||
|
||||
#### Scenario: probes 段不替换
|
||||
- **WHEN** probes 配置 `execution.maxConcurrentChecks: "${max_checks}"` 且 variables 中定义 `max_checks: 5`
|
||||
- **THEN** 系统 SHALL 保持 probes.execution.maxConcurrentChecks 为字面量 `"${max_checks}"`,不进行替换
|
||||
#### Scenario: target 嵌套 type 字段参与替换
|
||||
- **WHEN** target 内部非顶层身份字段配置 `expect.body[0].json.path: "$.${field_type}"` 且 variables 中定义 `field_type: "type"`
|
||||
- **THEN** 系统 SHALL 对该嵌套字段执行变量替换
|
||||
|
||||
#### Scenario: 对象 key 不替换
|
||||
- **WHEN** target 配置 `http.headers."${HEADER_NAME}": "demo"` 且 variables 中定义 `HEADER_NAME: "X-Demo"`
|
||||
- **THEN** 系统 SHALL 保持 header key 为字面量 `"${HEADER_NAME}"`,不进行替换
|
||||
|
||||
#### Scenario: defaults 段被拒绝
|
||||
- **WHEN** 配置文件声明顶层 `defaults`
|
||||
- **THEN** 系统 SHALL 在契约校验阶段拒绝该未知字段,而不是尝试变量替换
|
||||
|
||||
### Requirement: 变量替换错误报告
|
||||
变量替换阶段的错误 SHALL 作为 `ConfigValidationIssue` 输出,code 为 `unresolved-variable`。错误信息 SHALL 包含 target 索引、target id、字段路径和变量名。
|
||||
变量替换阶段的错误 SHALL 作为 `ConfigValidationIssue` 输出,code 为 `unresolved-variable`。错误信息 SHALL 包含字段路径和变量名。对于 `targets[i]` 内的错误,错误信息还 SHALL 包含 target 索引、target id 和 target 展示名上下文。
|
||||
|
||||
#### Scenario: 单个变量缺失报错
|
||||
#### Scenario: 单个 target 变量缺失报错
|
||||
- **WHEN** targets[0] (id: "api-health") 的 http.url 引用 `${base_url}` 但变量不存在
|
||||
- **THEN** 系统 SHALL 输出包含 target 索引 0、id "api-health"、路径 "http.url"、变量名 "base_url" 的错误信息
|
||||
|
||||
#### Scenario: server 变量缺失报错
|
||||
- **WHEN** server.listen.host 引用 `${server_host}` 但变量不存在
|
||||
- **THEN** 系统 SHALL 输出包含路径 "server.listen.host" 和变量名 "server_host" 的错误信息
|
||||
|
||||
#### Scenario: probes 变量缺失报错
|
||||
- **WHEN** probes.execution.maxConcurrentChecks 引用 `${max_checks}` 但变量不存在
|
||||
- **THEN** 系统 SHALL 输出包含路径 "probes.execution.maxConcurrentChecks" 和变量名 "max_checks" 的错误信息
|
||||
|
||||
#### Scenario: 多个变量缺失批量报错
|
||||
- **WHEN** 多个 target 的多个字段引用了不存在的变量
|
||||
- **WHEN** 多个配置字段引用了不存在的变量
|
||||
- **THEN** 系统 SHALL 收集所有缺失变量错误后统一输出,而非遇到第一个就退出
|
||||
|
||||
### Requirement: 变量替换执行时机
|
||||
变量替换 SHALL 在 YAML 解析之后、schema 契约校验(AJV)之前执行。替换完成后的配置对象 SHALL 传入后续校验流程。
|
||||
|
||||
#### Scenario: 替换后通过 schema 校验
|
||||
#### Scenario: target 替换后通过 schema 校验
|
||||
- **WHEN** target 配置 `http.maxRedirects: "${MAX_REDIRECTS}"` 且环境变量 `MAX_REDIRECTS=5`
|
||||
- **THEN** 系统 SHALL 先将该字段替换为 number 5,再进入 AJV 校验(期望 integer),校验通过
|
||||
|
||||
#### Scenario: 替换后未通过 schema 校验
|
||||
#### Scenario: target 替换后未通过 schema 校验
|
||||
- **WHEN** target 配置 `http.maxRedirects: "${MAX_REDIRECTS}"` 且环境变量 `MAX_REDIRECTS=abc`
|
||||
- **THEN** 系统 SHALL 先将该字段替换为 string "abc",再进入 AJV 校验(期望 integer),校验失败并报错
|
||||
|
||||
#### Scenario: server 替换后通过 schema 校验
|
||||
- **WHEN** server 配置 `listen.port: "${SERVER_PORT}"` 且 variables 中定义 `SERVER_PORT: 3000`
|
||||
- **THEN** 系统 SHALL 先将该字段替换为 number 3000,再进入 AJV 校验(期望 integer),校验通过
|
||||
|
||||
#### Scenario: probes 替换后通过 schema 校验
|
||||
- **WHEN** probes 配置 `execution.maxConcurrentChecks: "${MAX_CHECKS}"` 且 variables 中定义 `MAX_CHECKS: 20`
|
||||
- **THEN** 系统 SHALL 先将该字段替换为 number 20,再进入 AJV 校验(期望 integer),校验通过
|
||||
|
||||
@@ -63,14 +63,8 @@ export function resolveVariables(config: unknown): { config: unknown; issues: Co
|
||||
if (!isPlainObject(config)) {
|
||||
return { config, issues };
|
||||
}
|
||||
const configRecord = config as Record<string, unknown>;
|
||||
const rawTargets: unknown = configRecord["targets"];
|
||||
if (!Array.isArray(rawTargets)) {
|
||||
return { config, issues };
|
||||
}
|
||||
|
||||
const targets = rawTargets.map((target, index) => resolveTargetVariables(target, index, variables, issues));
|
||||
return { config: { ...config, targets }, issues };
|
||||
return { config: resolveConfigValue(config, "", variables, issues), issues };
|
||||
}
|
||||
|
||||
function describeInvalidVariableValue(value: unknown): string {
|
||||
@@ -80,6 +74,7 @@ function describeInvalidVariableValue(value: unknown): string {
|
||||
}
|
||||
|
||||
function inferStringValue(value: string): VariableValue {
|
||||
if (value === "") return value;
|
||||
const numberValue = Number(value);
|
||||
if (Number.isFinite(numberValue)) return numberValue;
|
||||
if (value === "true") return true;
|
||||
@@ -126,6 +121,31 @@ function replaceStringValue(
|
||||
return escaped.reduce((result, literal, index) => result.replace(`\u0000${index}\u0000`, literal), replaced);
|
||||
}
|
||||
|
||||
function resolveConfigValue(
|
||||
value: unknown,
|
||||
path: string,
|
||||
variables: Map<string, VariableValue>,
|
||||
issues: ConfigValidationIssue[],
|
||||
): unknown {
|
||||
if (!isPlainObject(value)) return value;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const itemPath = joinPath(path, key);
|
||||
if (key === "variables") {
|
||||
result[key] = item;
|
||||
continue;
|
||||
}
|
||||
if (key === "targets" && Array.isArray(item)) {
|
||||
result[key] = item.map((target, index) => resolveTargetVariables(target, index, variables, issues));
|
||||
continue;
|
||||
}
|
||||
result[key] =
|
||||
key === "server" || key === "probes" ? resolveValue(item, itemPath, variables, issues, { path: itemPath }) : item;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveTargetVariables(
|
||||
target: unknown,
|
||||
index: number,
|
||||
@@ -165,10 +185,9 @@ function resolveValue(
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const itemPath = joinPath(path, key);
|
||||
result[key] =
|
||||
key === "id" || key === "type"
|
||||
? item
|
||||
: resolveValue(item, itemPath, variables, issues, { ...context, path: itemPath });
|
||||
result[key] = shouldSkipVariableResolution(itemPath)
|
||||
? item
|
||||
: resolveValue(item, itemPath, variables, issues, { ...context, path: itemPath });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -202,3 +221,7 @@ function resolveVariableReference(
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldSkipVariableResolution(path: string): boolean {
|
||||
return /^targets\[\d+\]\.(?:id|type)$/.test(path);
|
||||
}
|
||||
|
||||
@@ -436,6 +436,49 @@ targets:
|
||||
expect(target.http.maxRedirects).toBe(5);
|
||||
});
|
||||
|
||||
test("server 和 probes 在 schema 校验前完成变量替换", async () => {
|
||||
const configPath = join(tempDir, "variables-server-probes.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`variables:
|
||||
server_host: "0.0.0.0"
|
||||
server_port: 3100
|
||||
retention: "24h"
|
||||
log_level: "debug"
|
||||
rotation_max_files: 30
|
||||
max_checks: 5
|
||||
server:
|
||||
listen:
|
||||
host: "\${server_host}"
|
||||
port: "\${server_port}"
|
||||
storage:
|
||||
retention: "\${retention}"
|
||||
logging:
|
||||
level: "\${log_level}"
|
||||
file:
|
||||
rotation:
|
||||
maxFiles: "\${rotation_max_files}"
|
||||
probes:
|
||||
execution:
|
||||
maxConcurrentChecks: "\${max_checks}"
|
||||
targets:
|
||||
- id: "server-vars"
|
||||
type: http
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.host).toBe("0.0.0.0");
|
||||
expect(config.port).toBe(3100);
|
||||
expect(config.retentionMs).toBe(86400000);
|
||||
expect(config.logging.consoleLevel).toBe("debug");
|
||||
expect(config.logging.fileLevel).toBe("debug");
|
||||
expect(config.logging.rotationMaxFiles).toBe(30);
|
||||
expect(config.maxConcurrentChecks).toBe(5);
|
||||
});
|
||||
|
||||
test("变量替换后类型不匹配导致 schema 校验失败", async () => {
|
||||
const configPath = join(tempDir, "bad-var-type.yaml");
|
||||
await writeFile(
|
||||
@@ -494,6 +537,38 @@ targets:
|
||||
await expectConfigLoadError(configPath, "未定义的变量");
|
||||
});
|
||||
|
||||
test("server 和 probes 中未定义变量阻止启动并输出字段路径", async () => {
|
||||
await expectConfigError(
|
||||
"unresolved-server-probes.yaml",
|
||||
`server:
|
||||
listen:
|
||||
host: "\${MISSING_SERVER_HOST}"
|
||||
probes:
|
||||
execution:
|
||||
maxConcurrentChecks: "\${MISSING_MAX_CHECKS}"
|
||||
targets:
|
||||
- id: "unresolved-root"
|
||||
type: http
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
"server.listen.host",
|
||||
);
|
||||
await expectConfigError(
|
||||
"unresolved-probes.yaml",
|
||||
`probes:
|
||||
execution:
|
||||
maxConcurrentChecks: "\${MISSING_MAX_CHECKS}"
|
||||
targets:
|
||||
- id: "unresolved-probes"
|
||||
type: http
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
"probes.execution.maxConcurrentChecks",
|
||||
);
|
||||
});
|
||||
|
||||
test("绝对 dataDir 保持不变", async () => {
|
||||
const dataDir = join(tempDir, "absolute-data");
|
||||
const configPath = join(tempDir, "absolute-data-dir.yaml");
|
||||
|
||||
@@ -150,36 +150,63 @@ describe("config variables", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("替换范围仅 targets,且跳过 id 和 type 字段", () => {
|
||||
test("替换范围覆盖 server、probes、targets,并跳过 variables 和 target 顶层 id/type", () => {
|
||||
const result = resolveVariables({
|
||||
defaults: { interval: "${interval}" },
|
||||
server: { host: "${host}" },
|
||||
probes: { execution: { maxConcurrentChecks: "${maxConcurrentChecks}" } },
|
||||
server: { host: "${host}", logging: { level: "${logLevel}" }, storage: { retention: "${retention}" } },
|
||||
targets: [
|
||||
{
|
||||
cmd: {
|
||||
args: ["--host", "${host}"],
|
||||
env: { TOKEN: "${token}" },
|
||||
env: { "${TOKEN_KEY}": "${token}" },
|
||||
exec: "echo",
|
||||
},
|
||||
expect: { rows: [{ id: { equals: "${expectedId}" }, type: { equals: "${expectedType}" } }] },
|
||||
id: "${id}",
|
||||
type: "${type}",
|
||||
},
|
||||
],
|
||||
variables: { host: "localhost", id: "resolved", interval: "30s", token: "abc", type: "cmd" },
|
||||
variables: {
|
||||
expectedId: 1,
|
||||
expectedType: "service",
|
||||
host: "localhost",
|
||||
id: "resolved",
|
||||
interval: "30s",
|
||||
logLevel: "debug",
|
||||
maxConcurrentChecks: 20,
|
||||
retention: "24h",
|
||||
token: "abc",
|
||||
TOKEN_KEY: "TOKEN",
|
||||
type: "cmd",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.issues).toHaveLength(0);
|
||||
const config = result.config as {
|
||||
defaults: { interval: string };
|
||||
server: { host: string };
|
||||
targets: Array<{ cmd: { args: string[]; env: Record<string, string> }; id: string; type: string }>;
|
||||
probes: { execution: { maxConcurrentChecks: number } };
|
||||
server: { host: string; logging: { level: string }; storage: { retention: string } };
|
||||
targets: Array<{
|
||||
cmd: { args: string[]; env: Record<string, string> };
|
||||
expect: { rows: Array<{ id: { equals: number }; type: { equals: string } }> };
|
||||
id: string;
|
||||
type: string;
|
||||
}>;
|
||||
variables: { host: string };
|
||||
};
|
||||
expect(config.server.host).toBe("${host}");
|
||||
expect(config.server.host).toBe("localhost");
|
||||
expect(config.server.logging.level).toBe("debug");
|
||||
expect(config.server.storage.retention).toBe("24h");
|
||||
expect(config.probes.execution.maxConcurrentChecks).toBe(20);
|
||||
expect(config.variables.host).toBe("localhost");
|
||||
expect(config.defaults.interval).toBe("${interval}");
|
||||
expect(config.targets[0]!.id).toBe("${id}");
|
||||
expect(config.targets[0]!.type).toBe("${type}");
|
||||
expect(config.targets[0]!.cmd.args[1]).toBe("localhost");
|
||||
expect(config.targets[0]!.cmd.env["TOKEN"]).toBe("abc");
|
||||
expect(config.targets[0]!.cmd.env["${TOKEN_KEY}"]).toBe("abc");
|
||||
expect(config.targets[0]!.expect.rows[0]!.id.equals).toBe(1);
|
||||
expect(config.targets[0]!.expect.rows[0]!.type.equals).toBe("service");
|
||||
});
|
||||
|
||||
test("默认值推断为 boolean(true/false)", () => {
|
||||
@@ -220,7 +247,39 @@ describe("config variables", () => {
|
||||
expect(typeof http2["ignoreSSL"]).toBe("boolean");
|
||||
});
|
||||
|
||||
test("server.storage 段不替换", () => {
|
||||
test("空默认值和空环境变量保持字符串空值", () => {
|
||||
const original = process.env["EMPTY_ENV_VALUE"];
|
||||
process.env["EMPTY_ENV_VALUE"] = "";
|
||||
|
||||
try {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${EMPTY_ENV_VALUE}" }, logging: { file: { path: "${OPTIONAL_LOG_PATH|}" } } },
|
||||
targets: [
|
||||
{
|
||||
http: { body: "${OPTIONAL_BODY|}", url: "${HOST|localhost}" },
|
||||
id: "empty-values",
|
||||
type: "http",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.issues).toHaveLength(0);
|
||||
const config = result.config as {
|
||||
server: { listen: { host: unknown }; logging: { file: { path: unknown } } };
|
||||
targets: Array<{ http: { body: unknown } }>;
|
||||
};
|
||||
expect(config.server.listen.host).toBe("");
|
||||
expect(typeof config.server.listen.host).toBe("string");
|
||||
expect(config.server.logging.file.path).toBe("");
|
||||
expect(typeof config.server.logging.file.path).toBe("string");
|
||||
expect(config.targets[0]!.http.body).toBe("");
|
||||
expect(typeof config.targets[0]!.http.body).toBe("string");
|
||||
} finally {
|
||||
restoreEnv("EMPTY_ENV_VALUE", original);
|
||||
}
|
||||
});
|
||||
|
||||
test("server.storage 段支持替换", () => {
|
||||
const result = resolveVariables({
|
||||
server: { storage: { retention: "${retention}" } },
|
||||
targets: [
|
||||
@@ -235,10 +294,10 @@ describe("config variables", () => {
|
||||
|
||||
expect(result.issues).toHaveLength(0);
|
||||
const config = result.config as { server: { storage: { retention: string } } };
|
||||
expect(config.server.storage.retention).toBe("${retention}");
|
||||
expect(config.server.storage.retention).toBe("24h");
|
||||
});
|
||||
|
||||
test("probes 段不替换", () => {
|
||||
test("probes 段支持替换", () => {
|
||||
const result = resolveVariables({
|
||||
probes: { execution: { maxConcurrentChecks: "${maxConcurrentChecks}" } },
|
||||
targets: [
|
||||
@@ -248,15 +307,15 @@ describe("config variables", () => {
|
||||
type: "http",
|
||||
},
|
||||
],
|
||||
variables: { host: "https://example.com", maxConcurrentChecks: "20" },
|
||||
variables: { host: "https://example.com", maxConcurrentChecks: 20 },
|
||||
});
|
||||
|
||||
expect(result.issues).toHaveLength(0);
|
||||
const config = result.config as { probes: { execution: { maxConcurrentChecks: string } } };
|
||||
expect(config.probes.execution.maxConcurrentChecks).toBe("${maxConcurrentChecks}");
|
||||
const config = result.config as { probes: { execution: { maxConcurrentChecks: number } } };
|
||||
expect(config.probes.execution.maxConcurrentChecks).toBe(20);
|
||||
});
|
||||
|
||||
test("server.logging 段不替换", () => {
|
||||
test("server.logging 段支持替换", () => {
|
||||
const result = resolveVariables({
|
||||
server: { logging: { level: "${logLevel}" } },
|
||||
targets: [
|
||||
@@ -271,7 +330,7 @@ describe("config variables", () => {
|
||||
|
||||
expect(result.issues).toHaveLength(0);
|
||||
const config = result.config as { server: { logging: { level: string } } };
|
||||
expect(config.server.logging.level).toBe("${logLevel}");
|
||||
expect(config.server.logging.level).toBe("debug");
|
||||
});
|
||||
|
||||
test("variables 段为非对象时报错", () => {
|
||||
|
||||
Reference in New Issue
Block a user