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:
155
src/server/checker/command-runner.ts
Normal file
155
src/server/checker/command-runner.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { CheckResult, ResolvedCommandTarget } from "./types";
|
||||
import { checkCommandExpect } from "./expect/command";
|
||||
import { errorFailure } from "./expect/failure";
|
||||
|
||||
async function readOutput(
|
||||
stdout: ReadableStream<Uint8Array>,
|
||||
stderr: ReadableStream<Uint8Array>,
|
||||
kill: () => void,
|
||||
maxBytes: number,
|
||||
): Promise<{ stdout: string; stderr: string; exceeded: boolean }> {
|
||||
let totalBytes = 0;
|
||||
let exceeded = false;
|
||||
let killed = false;
|
||||
|
||||
async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
totalBytes += value.byteLength;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
if (totalBytes > maxBytes && !killed) {
|
||||
exceeded = true;
|
||||
killed = true;
|
||||
try {
|
||||
kill();
|
||||
} catch {
|
||||
/* best-effort kill */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* stream already closed */
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
const [out, err] = await Promise.all([readStream(stdout), readStream(stderr)]);
|
||||
|
||||
return { stdout: out, stderr: err, exceeded };
|
||||
}
|
||||
|
||||
export async function runCommandCheck(target: ResolvedCommandTarget): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
|
||||
let proc: ReturnType<typeof Bun.spawn>;
|
||||
|
||||
try {
|
||||
proc = Bun.spawn([target.command.exec, ...target.command.args], {
|
||||
cwd: target.command.cwd,
|
||||
env: target.command.env,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: false,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "spawn", error instanceof Error ? error.message : String(error)),
|
||||
};
|
||||
}
|
||||
|
||||
let timedOut = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* best-effort kill */
|
||||
}
|
||||
}, target.timeoutMs);
|
||||
|
||||
let outputResult: { stdout: string; stderr: string; exceeded: boolean };
|
||||
|
||||
try {
|
||||
outputResult = await readOutput(
|
||||
proc.stdout as ReadableStream<Uint8Array>,
|
||||
proc.stderr as ReadableStream<Uint8Array>,
|
||||
() => proc.kill(),
|
||||
target.command.maxOutputBytes,
|
||||
);
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: false,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
||||
};
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const exitCode = proc.exitCode ?? 1;
|
||||
|
||||
if (outputResult.exceeded) {
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: false,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: errorFailure("exitCode", "output", `输出超过限制 ${target.command.maxOutputBytes} 字节`),
|
||||
};
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: false,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${target.timeoutMs}ms)`),
|
||||
};
|
||||
}
|
||||
|
||||
const obs = { exitCode, stdout: outputResult.stdout, stderr: outputResult.stderr, durationMs };
|
||||
const expectResult = checkCommandExpect(obs, target.expect);
|
||||
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: expectResult.matched,
|
||||
matched: expectResult.matched,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: expectResult.failure,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user