feat: 重构为多类型 checker 通用框架,支持 HTTP 与命令检查
- 引入 typed target 判别联合,支持 http 与 command 两种 checker - expect 重构为有序规则数组,按配置顺序快速失败并生成结构化 failure - 新增 command runner,支持 exec + args 本地命令执行 - 引入全局并发限制 maxConcurrentChecks 和 size 解析 (KB/MB/GB) - HTTP/command 各自独立 expect pipeline,应用领域默认成功语义 - SQLite schema、API、Dashboard 全链路调整为 checker 通用契约 - 补充完整测试覆盖(192 tests),更新 README 与示例配置
This commit is contained in:
@@ -55,6 +55,66 @@ describe("loadConfig", () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("解析最简 HTTP 配置", async () => {
|
||||
const configPath = join(tempDir, "minimal-http.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "test"
|
||||
type: http
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.host).toBe("127.0.0.1");
|
||||
expect(config.port).toBe(3000);
|
||||
expect(config.dataDir).toBe("./data");
|
||||
expect(config.maxConcurrentChecks).toBe(20);
|
||||
expect(config.targets).toHaveLength(1);
|
||||
const t = config.targets[0]!;
|
||||
expect(t.type).toBe("http");
|
||||
if (t.type === "http") {
|
||||
expect(t.name).toBe("test");
|
||||
expect(t.http.url).toBe("http://example.com");
|
||||
expect(t.http.method).toBe("GET");
|
||||
expect(t.http.headers).toEqual({});
|
||||
expect(t.http.maxBodyBytes).toBe(104857600);
|
||||
expect(t.intervalMs).toBe(30000);
|
||||
expect(t.timeoutMs).toBe(10000);
|
||||
}
|
||||
});
|
||||
|
||||
test("解析最简 command 配置", async () => {
|
||||
const subdir = join(tempDir, "subdir");
|
||||
await mkdir(subdir, { recursive: true });
|
||||
const configPath = join(subdir, "cmd.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "check-nginx"
|
||||
type: command
|
||||
command:
|
||||
exec: "pgrep"
|
||||
args: ["nginx"]
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.targets).toHaveLength(1);
|
||||
const t = config.targets[0]!;
|
||||
expect(t.type).toBe("command");
|
||||
if (t.type === "command") {
|
||||
expect(t.name).toBe("check-nginx");
|
||||
expect(t.command.exec).toBe("pgrep");
|
||||
expect(t.command.args).toEqual(["nginx"]);
|
||||
expect(t.command.cwd).toBe(subdir);
|
||||
expect(t.command.maxOutputBytes).toBe(104857600);
|
||||
expect(t.command.env.PATH).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("解析完整配置", async () => {
|
||||
const configPath = join(tempDir, "full.yaml");
|
||||
await writeFile(
|
||||
@@ -63,13 +123,36 @@ describe("loadConfig", () => {
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
dataDir: "./my-data"
|
||||
runtime:
|
||||
maxConcurrentChecks: 5
|
||||
defaults:
|
||||
interval: "15s"
|
||||
timeout: "5s"
|
||||
method: "POST"
|
||||
http:
|
||||
method: "POST"
|
||||
headers:
|
||||
Authorization: "Bearer token"
|
||||
maxBodyBytes: "50MB"
|
||||
command:
|
||||
cwd: "/tmp"
|
||||
maxOutputBytes: "10MB"
|
||||
targets:
|
||||
- name: "test"
|
||||
url: "http://example.com"
|
||||
- name: "http-target"
|
||||
type: http
|
||||
interval: "1m"
|
||||
http:
|
||||
url: "http://example.com"
|
||||
expect:
|
||||
status: [200]
|
||||
body:
|
||||
- contains: "ok"
|
||||
- name: "cmd-target"
|
||||
type: command
|
||||
command:
|
||||
exec: "ls"
|
||||
args: ["/tmp"]
|
||||
expect:
|
||||
exitCode: [0]
|
||||
`,
|
||||
);
|
||||
|
||||
@@ -77,39 +160,27 @@ targets:
|
||||
expect(config.host).toBe("0.0.0.0");
|
||||
expect(config.port).toBe(8080);
|
||||
expect(config.dataDir).toBe("./my-data");
|
||||
expect(config.targets).toHaveLength(1);
|
||||
expect(config.targets[0]).toEqual({
|
||||
name: "test",
|
||||
url: "http://example.com",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
body: undefined,
|
||||
intervalMs: 15000,
|
||||
timeoutMs: 5000,
|
||||
expect: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("解析最简配置(只有 targets)", async () => {
|
||||
const configPath = join(tempDir, "minimal.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "t1"
|
||||
url: "http://a.com"
|
||||
- name: "t2"
|
||||
url: "http://b.com"
|
||||
interval: "1m"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.host).toBe("127.0.0.1");
|
||||
expect(config.port).toBe(3000);
|
||||
expect(config.dataDir).toBe("./data");
|
||||
expect(config.maxConcurrentChecks).toBe(5);
|
||||
expect(config.targets).toHaveLength(2);
|
||||
expect(config.targets[0]!.intervalMs).toBe(30000);
|
||||
expect(config.targets[1]!.intervalMs).toBe(60000);
|
||||
|
||||
const http = config.targets[0]!;
|
||||
expect(http.type).toBe("http");
|
||||
if (http.type === "http") {
|
||||
expect(http.http.url).toBe("http://example.com");
|
||||
expect(http.http.method).toBe("POST");
|
||||
expect(http.http.headers).toEqual({ Authorization: "Bearer token" });
|
||||
expect(http.http.maxBodyBytes).toBe(52428800);
|
||||
expect(http.intervalMs).toBe(60000);
|
||||
expect(http.timeoutMs).toBe(5000);
|
||||
}
|
||||
|
||||
const cmd = config.targets[1]!;
|
||||
expect(cmd.type).toBe("command");
|
||||
if (cmd.type === "command") {
|
||||
expect(cmd.command.exec).toBe("ls");
|
||||
expect(cmd.command.args).toEqual(["/tmp"]);
|
||||
expect(cmd.command.maxOutputBytes).toBe(10485760);
|
||||
}
|
||||
});
|
||||
|
||||
test("per-target 覆盖 defaults", async () => {
|
||||
@@ -119,26 +190,29 @@ targets:
|
||||
`defaults:
|
||||
interval: "30s"
|
||||
timeout: "10s"
|
||||
method: "GET"
|
||||
headers:
|
||||
Authorization: "Bearer token"
|
||||
http:
|
||||
method: "GET"
|
||||
maxBodyBytes: "10MB"
|
||||
targets:
|
||||
- name: "override-all"
|
||||
url: "http://example.com"
|
||||
method: "POST"
|
||||
type: http
|
||||
interval: "5m"
|
||||
timeout: "30s"
|
||||
headers:
|
||||
X-Custom: "value"
|
||||
http:
|
||||
url: "http://example.com"
|
||||
method: "POST"
|
||||
maxBodyBytes: "1MB"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
const target = config.targets[0]!;
|
||||
expect(target.method).toBe("POST");
|
||||
expect(target.intervalMs).toBe(300000);
|
||||
expect(target.timeoutMs).toBe(30000);
|
||||
expect(target.headers).toEqual({ Authorization: "Bearer token", "X-Custom": "value" });
|
||||
const t = config.targets[0]!;
|
||||
if (t.type === "http") {
|
||||
expect(t.http.method).toBe("POST");
|
||||
expect(t.intervalMs).toBe(300000);
|
||||
expect(t.timeoutMs).toBe(30000);
|
||||
expect(t.http.maxBodyBytes).toBe(1048576);
|
||||
}
|
||||
});
|
||||
|
||||
test("配置文件不存在抛出错误", async () => {
|
||||
@@ -150,23 +224,63 @@ targets:
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- url: "http://example.com"
|
||||
- type: http
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 name 字段");
|
||||
});
|
||||
|
||||
test("target 缺少 url 抛出错误", async () => {
|
||||
test("target 缺少 type 抛出错误", async () => {
|
||||
const configPath = join(tempDir, "no-type.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "test"
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 type 字段");
|
||||
});
|
||||
|
||||
test("HTTP target 缺少 url 抛出错误", async () => {
|
||||
const configPath = join(tempDir, "no-url.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "test"
|
||||
type: http
|
||||
http: {}
|
||||
`,
|
||||
);
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段");
|
||||
});
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 url 字段");
|
||||
test("command target 缺少 exec 抛出错误", async () => {
|
||||
const configPath = join(tempDir, "no-exec.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "test"
|
||||
type: command
|
||||
command: {}
|
||||
`,
|
||||
);
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 command.exec 字段");
|
||||
});
|
||||
|
||||
test("非法 target type 抛出错误", async () => {
|
||||
const configPath = join(tempDir, "bad-type.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "test"
|
||||
type: dns
|
||||
`,
|
||||
);
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("不支持的 type");
|
||||
});
|
||||
|
||||
test("target name 重复抛出错误", async () => {
|
||||
@@ -175,19 +289,21 @@ targets:
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "dup"
|
||||
url: "http://a.com"
|
||||
type: http
|
||||
http:
|
||||
url: "http://a.com"
|
||||
- name: "dup"
|
||||
url: "http://b.com"
|
||||
type: http
|
||||
http:
|
||||
url: "http://b.com"
|
||||
`,
|
||||
);
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("target name 重复");
|
||||
});
|
||||
|
||||
test("targets 为空数组抛出错误", async () => {
|
||||
const configPath = join(tempDir, "empty-targets.yaml");
|
||||
await writeFile(configPath, `targets: []`);
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("至少一个 target");
|
||||
});
|
||||
|
||||
@@ -199,33 +315,168 @@ targets:
|
||||
port: 99999
|
||||
targets:
|
||||
- name: "t"
|
||||
url: "http://a.com"
|
||||
type: http
|
||||
http:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("无效端口号");
|
||||
});
|
||||
|
||||
test("非法 maxConcurrentChecks 抛出错误", async () => {
|
||||
const configPath = join(tempDir, "bad-concurrency.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`runtime:
|
||||
maxConcurrentChecks: -1
|
||||
targets:
|
||||
- name: "t"
|
||||
type: http
|
||||
http:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("maxConcurrentChecks 必须为正整数");
|
||||
});
|
||||
|
||||
test("非法 size 格式抛出错误", async () => {
|
||||
const configPath = join(tempDir, "bad-size.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`defaults:
|
||||
http:
|
||||
maxBodyBytes: "100TB"
|
||||
targets:
|
||||
- name: "t"
|
||||
type: http
|
||||
http:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("无效的 size 格式");
|
||||
});
|
||||
|
||||
test("非法 interval 格式抛出错误", async () => {
|
||||
const configPath = join(tempDir, "bad-interval.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "t"
|
||||
type: http
|
||||
interval: "30x"
|
||||
http:
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("无效的时长格式");
|
||||
});
|
||||
|
||||
test("解析 expect 配置", async () => {
|
||||
const configPath = join(tempDir, "expect.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "with-expect"
|
||||
url: "http://example.com"
|
||||
type: http
|
||||
http:
|
||||
url: "http://example.com"
|
||||
expect:
|
||||
status: [200, 201]
|
||||
body:
|
||||
contains: "ok"
|
||||
maxLatencyMs: 3000
|
||||
- contains: "ok"
|
||||
- json:
|
||||
path: "$.status"
|
||||
equals: "ok"
|
||||
maxDurationMs: 3000
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.targets[0]!.expect).toEqual({
|
||||
status: [200, 201],
|
||||
body: { contains: "ok" },
|
||||
maxLatencyMs: 3000,
|
||||
});
|
||||
const t = config.targets[0]!;
|
||||
if (t.type === "http") {
|
||||
expect(t.expect).toEqual({
|
||||
status: [200, 201],
|
||||
body: [{ contains: "ok" }, { json: { path: "$.status", equals: "ok" } }],
|
||||
maxDurationMs: 3000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("解析 command expect 配置", async () => {
|
||||
const configPath = join(tempDir, "cmd-expect.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "cmd-with-expect"
|
||||
type: command
|
||||
command:
|
||||
exec: "mycheck"
|
||||
expect:
|
||||
exitCode: [0, 2]
|
||||
stdout:
|
||||
- contains: "ok"
|
||||
- match: "done"
|
||||
stderr:
|
||||
- empty: true
|
||||
maxDurationMs: 5000
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
const t = config.targets[0]!;
|
||||
if (t.type === "command") {
|
||||
expect(t.expect).toEqual({
|
||||
exitCode: [0, 2],
|
||||
stdout: [{ contains: "ok" }, { match: "done" }],
|
||||
stderr: [{ empty: true }],
|
||||
maxDurationMs: 5000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("command cwd 相对配置文件目录", async () => {
|
||||
const subdir = join(tempDir, "cwd-test");
|
||||
await mkdir(subdir, { recursive: true });
|
||||
const configPath = join(subdir, "cwd.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "cwd-test"
|
||||
type: command
|
||||
command:
|
||||
exec: "ls"
|
||||
cwd: "scripts"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
const t = config.targets[0]!;
|
||||
if (t.type === "command") {
|
||||
expect(t.command.cwd).toBe(join(subdir, "scripts"));
|
||||
}
|
||||
});
|
||||
|
||||
test("command env 覆盖", async () => {
|
||||
const configPath = join(tempDir, "env.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "env-test"
|
||||
type: command
|
||||
command:
|
||||
exec: "echo"
|
||||
env:
|
||||
LANG: "C"
|
||||
CUSTOM_VAR: "test"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
const t = config.targets[0]!;
|
||||
if (t.type === "command") {
|
||||
expect(t.command.env.LANG).toBe("C");
|
||||
expect(t.command.env.CUSTOM_VAR).toBe("test");
|
||||
expect(t.command.env.PATH).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user