1
0

feat: 配置变量系统与 target id/name 双字段标识

- 新增顶层 variables 段支持 string/number/boolean 字面量
- target 字符串字段支持 、、{...} 转义语法
- 变量解析优先级: variables -> process.env -> 默认值 -> 报错
- 完整引用保留原始类型,部分引用拼接为字符串
- 变量替换在 YAML 解析后、AJV 校验前执行
- 替换仅作用于 targets,跳过 id/type 字段
- target 新增必填 id 字段作为唯一标识,name 改为可选展示名称
- 数据库存储/API/前端全面迁移到 id 标识
- 统一 checker 运行时类型检查为 es-toolkit predicates
- 同步 delta specs 到主 specs,归档 config-variables 变更
This commit is contained in:
2026-05-17 00:37:54 +08:00
parent 366b3211c8
commit 7926514986
53 changed files with 1538 additions and 333 deletions

View File

@@ -107,6 +107,7 @@ describe("loadConfig", () => {
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -121,6 +122,7 @@ describe("loadConfig", () => {
expect(config.targets).toHaveLength(1);
const t = config.targets[0]! as ResolvedHttpTarget;
expect(t.type).toBe("http");
expect(t.id).toBe("test");
expect(t.name).toBe("test");
expect(t.http.url).toBe("http://example.com");
expect(t.http.method).toBe("GET");
@@ -140,6 +142,7 @@ describe("loadConfig", () => {
configPath,
`targets:
- name: "check-nginx"
id: "check-nginx"
type: cmd
cmd:
exec: "pgrep"
@@ -151,6 +154,7 @@ describe("loadConfig", () => {
expect(config.targets).toHaveLength(1);
const t = config.targets[0]! as ResolvedCommandTarget;
expect(t.type).toBe("cmd");
expect(t.id).toBe("check-nginx");
expect(t.name).toBe("check-nginx");
expect(t.cmd.exec).toBe("pgrep");
expect(t.cmd.args).toEqual(["nginx"]);
@@ -181,6 +185,7 @@ defaults:
maxOutputBytes: "10MB"
targets:
- name: "http-target"
id: "http-target"
type: http
interval: "1m"
http:
@@ -193,6 +198,7 @@ targets:
body:
- contains: "ok"
- name: "cmd-target"
id: "cmd-target"
type: cmd
cmd:
exec: "ls"
@@ -228,6 +234,140 @@ targets:
expect(cmd.cmd.maxOutputBytes).toBe(10485760);
});
test("name 缺省时 fallback 到 id", async () => {
const configPath = join(tempDir, "name-fallback.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0]!;
expect(target.id).toBe("api-health");
expect(target.name).toBe("api-health");
});
test("name 支持变量替换且不要求唯一", async () => {
const configPath = join(tempDir, "name-variable.yaml");
await writeFile(
configPath,
`variables:
env: "生产"
targets:
- id: "api-a"
name: "\${env} API"
type: http
http:
url: "http://a.example.com"
- id: "api-b"
name: "\${env} API"
type: http
http:
url: "http://b.example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets.map((target) => [target.id, target.name])).toEqual([
["api-a", "生产 API"],
["api-b", "生产 API"],
]);
});
test("包含 variables 的完整配置在 schema 校验前完成替换", async () => {
const configPath = join(tempDir, "variables-full.yaml");
await writeFile(
configPath,
`variables:
env: "生产"
base_url: "https://example.com"
ignore_ssl: true
max_redirects: 5
targets:
- id: "api-health"
name: "\${env} API 健康检查"
type: http
http:
url: "\${base_url}/health"
ignoreSSL: "\${ignore_ssl}"
maxRedirects: "\${max_redirects}"
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0] as ResolvedHttpTarget;
expect(target.id).toBe("api-health");
expect(target.name).toBe("生产 API 健康检查");
expect(target.http.url).toBe("https://example.com/health");
expect(target.http.ignoreSSL).toBe(true);
expect(target.http.maxRedirects).toBe(5);
});
test("变量替换后类型不匹配导致 schema 校验失败", async () => {
const configPath = join(tempDir, "bad-var-type.yaml");
await writeFile(
configPath,
`variables:
max_redirects: "not-a-number"
targets:
- id: "bad-var"
type: http
http:
url: "http://example.com"
maxRedirects: "\${max_redirects}"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxRedirects");
});
test("变量替换后通过 schema 校验", async () => {
const configPath = join(tempDir, "good-var-type.yaml");
const origEnv = process.env["DIAL_VAR_MAX_REDIRECTS"];
process.env["DIAL_VAR_MAX_REDIRECTS"] = "3";
try {
await writeFile(
configPath,
`targets:
- id: "good-var"
type: http
http:
url: "http://example.com"
maxRedirects: "\${DIAL_VAR_MAX_REDIRECTS}"
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0] as ResolvedHttpTarget;
expect(target.http.maxRedirects).toBe(3);
} finally {
if (origEnv === undefined) {
delete process.env["DIAL_VAR_MAX_REDIRECTS"];
} else {
process.env["DIAL_VAR_MAX_REDIRECTS"] = origEnv;
}
}
});
test("未定义变量且无默认值阻止启动", async () => {
const configPath = join(tempDir, "unresolved-var.yaml");
await writeFile(
configPath,
`targets:
- id: "unresolved"
type: http
http:
url: "\${MISSING_BASE_URL}/health"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("未定义的变量");
});
test("绝对 dataDir 保持不变", async () => {
const dataDir = join(tempDir, "absolute-data");
const configPath = join(tempDir, "absolute-data-dir.yaml");
@@ -237,6 +377,7 @@ targets:
dataDir: ${JSON.stringify(dataDir)}
targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -258,6 +399,7 @@ targets:
maxBodyBytes: "10MB"
targets:
- name: "override-all"
id: "override-all"
type: http
interval: "5m"
timeout: "30s"
@@ -281,8 +423,8 @@ targets:
await expect(loadConfig("/nonexistent/file.yaml")).rejects.toThrow("配置文件不存在");
});
test("target 缺少 name 抛出错误", async () => {
const configPath = join(tempDir, "no-name.yaml");
test("target 缺少 id 抛出错误", async () => {
const configPath = join(tempDir, "no-id.yaml");
await writeFile(
configPath,
`targets:
@@ -292,7 +434,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("缺少 name 字段");
await expect(loadConfig(configPath)).rejects.toThrow("缺少 id 字段");
});
test("target 缺少 type 抛出错误", async () => {
@@ -301,6 +443,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
http:
url: "http://example.com"
`,
@@ -315,6 +458,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http: {}
`,
@@ -329,6 +473,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
`,
);
@@ -342,6 +487,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -358,6 +504,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -374,6 +521,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -391,6 +539,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: cmd
cmd: {}
`,
@@ -405,6 +554,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: dns
`,
);
@@ -412,23 +562,70 @@ targets:
await expect(loadConfig(configPath)).rejects.toThrow("不支持的 type");
});
test("target name 重复抛出错误", async () => {
const configPath = join(tempDir, "dup-name.yaml");
test("target id 重复抛出错误", async () => {
const configPath = join(tempDir, "dup-id.yaml");
await writeFile(
configPath,
`targets:
- name: "dup"
id: "dup"
type: http
http:
url: "http://a.com"
- name: "dup"
id: "dup"
type: http
http:
url: "http://b.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("target name 重复");
await expect(loadConfig(configPath)).rejects.toThrow("target id 重复");
});
test("target id 为空字符串抛出错误", async () => {
const configPath = join(tempDir, "empty-id.yaml");
await writeFile(
configPath,
`targets:
- id: ""
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("缺少 id 字段");
});
test("target id 命名不合法抛出错误", async () => {
const configPath = join(tempDir, "bad-id.yaml");
await writeFile(
configPath,
`targets:
- id: "_invalid"
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("id 不符合命名规则");
});
test("target id 包含下划线和连字符通过", async () => {
const configPath = join(tempDir, "id-underscore-dash.yaml");
await writeFile(
configPath,
`targets:
- id: "db_check-01"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.id).toBe("db_check-01");
});
test("targets 为空数组抛出错误", async () => {
@@ -446,6 +643,7 @@ targets:
port: 99999
targets:
- name: "t"
id: "t"
type: http
http:
url: "http://a.com"
@@ -463,6 +661,7 @@ targets:
maxConcurrentChecks: -1
targets:
- name: "t"
id: "t"
type: http
http:
url: "http://a.com"
@@ -481,6 +680,7 @@ targets:
maxBodyBytes: "100TB"
targets:
- name: "t"
id: "t"
type: http
http:
url: "http://a.com"
@@ -496,6 +696,7 @@ targets:
configPath,
`targets:
- name: "t"
id: "t"
type: http
interval: "30x"
http:
@@ -512,6 +713,7 @@ targets:
configPath,
`targets:
- name: "with-expect"
id: "with-expect"
type: http
http:
url: "http://example.com"
@@ -543,6 +745,7 @@ targets:
configPath,
`targets:
- name: "cmd-with-expect"
id: "cmd-with-expect"
type: cmd
cmd:
exec: "mycheck"
@@ -577,6 +780,7 @@ targets:
configPath,
`targets:
- name: "cwd-test"
id: "cwd-test"
type: cmd
cmd:
exec: "ls"
@@ -595,6 +799,7 @@ targets:
configPath,
`targets:
- name: "env-test"
id: "env-test"
type: cmd
cmd:
exec: "echo"
@@ -617,6 +822,7 @@ targets:
configPath,
`targets:
- name: "grouped"
id: "grouped"
type: http
group: "搜索引擎"
http:
@@ -634,6 +840,7 @@ targets:
configPath,
`targets:
- name: "no-group"
id: "no-group"
type: http
http:
url: "http://example.com"
@@ -650,6 +857,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
group: 123
http:
@@ -667,6 +875,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -684,6 +893,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -700,6 +910,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -716,6 +927,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -732,6 +944,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -749,6 +962,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -766,6 +980,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -783,6 +998,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -800,6 +1016,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -818,6 +1035,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -836,6 +1054,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -855,6 +1074,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -873,6 +1093,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -893,6 +1114,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -912,6 +1134,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -931,6 +1154,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -951,6 +1175,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -971,6 +1196,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -993,6 +1219,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1012,6 +1239,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1028,6 +1256,7 @@ targets:
"lowercase-method.yaml",
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1045,6 +1274,7 @@ targets:
method: POST
targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1059,6 +1289,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1081,6 +1312,7 @@ targets:
X-Default-Header: "default"
targets:
- name: "http-test"
id: "http-test"
type: http
http:
url: "http://example.com"
@@ -1091,6 +1323,7 @@ targets:
X-Response-Header:
contains: "ok"
- name: "cmd-test"
id: "cmd-test"
type: cmd
cmd:
exec: "true"
@@ -1113,6 +1346,7 @@ targets:
"bad-cmd-args.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1127,6 +1361,7 @@ targets:
"bad-cmd-cwd.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1141,6 +1376,7 @@ targets:
"bad-cmd-env.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1156,6 +1392,7 @@ targets:
"bad-cmd-max-output.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1170,6 +1407,7 @@ targets:
"bad-cmd-exit-code.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1185,6 +1423,7 @@ targets:
"bad-cmd-stdout-empty.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1201,6 +1440,7 @@ targets:
"bad-cmd-stderr-operator.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1217,6 +1457,7 @@ targets:
"bad-cmd-stdout-regex.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1233,6 +1474,7 @@ targets:
"bad-cmd-expect-unknown.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1249,6 +1491,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1266,6 +1509,7 @@ targets:
retention: "24h"
targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1282,6 +1526,7 @@ targets:
retention: "7x"
targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"