- 移除 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
322 lines
8.7 KiB
TypeScript
322 lines
8.7 KiB
TypeScript
import { isError } from "es-toolkit";
|
|
import { resolve } from "node:path";
|
|
|
|
import type { CheckResult, RawTargetConfig } from "../../types";
|
|
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
|
import type {
|
|
CommandTargetConfig,
|
|
RawCommandExpectConfig,
|
|
ResolvedCommandExpectConfig,
|
|
ResolvedCommandTarget,
|
|
} from "./types";
|
|
|
|
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content";
|
|
import { errorFailure } from "../../expect/failure";
|
|
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
|
|
import { parseSize } from "../../utils";
|
|
import { checkExitCode } from "./expect";
|
|
import { commandCheckerSchemas } from "./schema";
|
|
import { validateCommandConfig } from "./validate";
|
|
|
|
const STDOUT_PREVIEW_MAX = 1024;
|
|
const STDERR_PREVIEW_MAX = 1024;
|
|
|
|
export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget> {
|
|
readonly configKey = "cmd";
|
|
|
|
readonly schemas = commandCheckerSchemas;
|
|
|
|
readonly type = "cmd";
|
|
|
|
buildDetail(observation: Record<string, unknown>): null | string {
|
|
const exitCode = observation["exitCode"];
|
|
return typeof exitCode === "number" ? `exitCode=${exitCode}` : null;
|
|
}
|
|
|
|
async execute(t: ResolvedCommandTarget, ctx: CheckerContext): Promise<CheckResult> {
|
|
const timestamp = new Date().toISOString();
|
|
const start = performance.now();
|
|
|
|
let proc: ReturnType<typeof Bun.spawn>;
|
|
|
|
try {
|
|
proc = Bun.spawn([t.cmd.exec, ...t.cmd.args], {
|
|
cwd: t.cmd.cwd,
|
|
env: t.cmd.env,
|
|
stderr: "pipe",
|
|
stdin: "ignore",
|
|
stdout: "pipe",
|
|
});
|
|
} catch (error) {
|
|
const durationMs = Math.round(performance.now() - start);
|
|
return {
|
|
detail: null,
|
|
durationMs,
|
|
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
|
|
matched: false,
|
|
observation: null,
|
|
targetId: t.id,
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
ctx.signal.addEventListener(
|
|
"abort",
|
|
() => {
|
|
try {
|
|
proc.kill();
|
|
} catch {
|
|
/* best-effort kill */
|
|
}
|
|
},
|
|
{ once: true },
|
|
);
|
|
|
|
let outputResult: { exceeded: boolean; stderr: string; stdout: string };
|
|
|
|
try {
|
|
outputResult = await readOutput(
|
|
proc.stdout as ReadableStream<Uint8Array>,
|
|
proc.stderr as ReadableStream<Uint8Array>,
|
|
() => proc.kill(),
|
|
t.cmd.maxOutputBytes,
|
|
);
|
|
} catch {
|
|
const durationMs = Math.round(performance.now() - start);
|
|
return {
|
|
detail: null,
|
|
durationMs,
|
|
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
|
matched: false,
|
|
observation: null,
|
|
targetId: t.id,
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
await proc.exited;
|
|
|
|
const durationMs = Math.round(performance.now() - start);
|
|
const exitCode = proc.exitCode ?? 1;
|
|
const stdoutPreview = truncatePreview(outputResult.stdout, STDOUT_PREVIEW_MAX);
|
|
const stderrPreview = truncatePreview(outputResult.stderr, STDERR_PREVIEW_MAX);
|
|
const observation: Record<string, unknown> = { error: null, exitCode, stderrPreview, stdoutPreview };
|
|
|
|
if (outputResult.exceeded) {
|
|
const message = `输出超过限制 ${t.cmd.maxOutputBytes} 字节`;
|
|
observation["error"] = message;
|
|
return {
|
|
detail: null,
|
|
durationMs,
|
|
failure: errorFailure("exitCode", "output", message),
|
|
matched: false,
|
|
observation,
|
|
targetId: t.id,
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
if (ctx.signal.aborted) {
|
|
const message = `命令执行超时 (${t.timeoutMs}ms)`;
|
|
observation["error"] = message;
|
|
return {
|
|
detail: null,
|
|
durationMs,
|
|
failure: errorFailure("exitCode", "timeout", message),
|
|
matched: false,
|
|
observation,
|
|
targetId: t.id,
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
const exitCodeResult = checkExitCode(exitCode, t.expect?.exitCode ?? [0]);
|
|
if (!exitCodeResult.matched) {
|
|
return {
|
|
detail: null,
|
|
durationMs,
|
|
failure: exitCodeResult.failure,
|
|
matched: false,
|
|
observation,
|
|
targetId: t.id,
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
const durationResult = checkValueExpectation(durationMs, t.expect?.durationMs, {
|
|
message: "durationMs mismatch",
|
|
path: "durationMs",
|
|
phase: "duration",
|
|
});
|
|
if (!durationResult.matched) {
|
|
return {
|
|
detail: null,
|
|
durationMs,
|
|
failure: durationResult.failure,
|
|
matched: false,
|
|
observation,
|
|
targetId: t.id,
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
if (t.expect?.stdout && t.expect.stdout.length > 0) {
|
|
const stdoutResult = checkContentExpectations(outputResult.stdout, t.expect.stdout, {
|
|
path: "stdout",
|
|
phase: "stdout",
|
|
});
|
|
if (!stdoutResult.matched) {
|
|
return {
|
|
detail: null,
|
|
durationMs,
|
|
failure: stdoutResult.failure,
|
|
matched: false,
|
|
observation,
|
|
targetId: t.id,
|
|
timestamp,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (t.expect?.stderr && t.expect.stderr.length > 0) {
|
|
const stderrResult = checkContentExpectations(outputResult.stderr, t.expect.stderr, {
|
|
path: "stderr",
|
|
phase: "stderr",
|
|
});
|
|
if (!stderrResult.matched) {
|
|
return {
|
|
detail: null,
|
|
durationMs,
|
|
failure: stderrResult.failure,
|
|
matched: false,
|
|
observation,
|
|
targetId: t.id,
|
|
timestamp,
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
detail: null,
|
|
durationMs,
|
|
failure: null,
|
|
matched: true,
|
|
observation,
|
|
targetId: t.id,
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCommandTarget {
|
|
const t = target as RawTargetConfig & { cmd: CommandTargetConfig; type: "cmd" };
|
|
|
|
const cwd = t.cmd.cwd ?? ".";
|
|
const resolvedCwd = resolve(context.configDir, cwd);
|
|
|
|
const maxOutputBytes = parseSize(t.cmd.maxOutputBytes ?? "100MB");
|
|
|
|
const env = { ...process.env, ...(t.cmd.env ?? {}) } as Record<string, string>;
|
|
|
|
const rawExpect = target.expect as RawCommandExpectConfig | undefined;
|
|
const resolvedExpect: ResolvedCommandExpectConfig = rawExpect
|
|
? {
|
|
durationMs: resolveValueExpectation(rawExpect.durationMs),
|
|
exitCode: rawExpect.exitCode ?? [0],
|
|
stderr: resolveContentExpectations(rawExpect.stderr),
|
|
stdout: resolveContentExpectations(rawExpect.stdout),
|
|
}
|
|
: { exitCode: [0] };
|
|
|
|
return {
|
|
cmd: {
|
|
args: t.cmd.args ?? [],
|
|
cwd: resolvedCwd,
|
|
env,
|
|
exec: t.cmd.exec,
|
|
maxOutputBytes,
|
|
},
|
|
description: null,
|
|
expect: resolvedExpect,
|
|
group: target.group ?? "default",
|
|
id: t.id,
|
|
intervalMs: context.defaultIntervalMs,
|
|
name: t.name ?? null,
|
|
rawExpect,
|
|
timeoutMs: context.defaultTimeoutMs,
|
|
type: "cmd",
|
|
} satisfies ResolvedCommandTarget;
|
|
}
|
|
|
|
serialize(t: ResolvedCommandTarget): { config: string; target: string } {
|
|
const parts = [t.cmd.exec, ...t.cmd.args];
|
|
return {
|
|
config: JSON.stringify({
|
|
args: t.cmd.args,
|
|
cwd: t.cmd.cwd,
|
|
env: t.cmd.env,
|
|
exec: t.cmd.exec,
|
|
maxOutputBytes: t.cmd.maxOutputBytes,
|
|
}),
|
|
target: `exec ${parts.join(" ")}`,
|
|
};
|
|
}
|
|
|
|
validate(input: CheckerValidationInput) {
|
|
return validateCommandConfig(input);
|
|
}
|
|
}
|
|
|
|
async function readOutput(
|
|
stdout: ReadableStream<Uint8Array>,
|
|
stderr: ReadableStream<Uint8Array>,
|
|
kill: () => void,
|
|
maxBytes: number,
|
|
): Promise<{ exceeded: boolean; stderr: string; stdout: string }> {
|
|
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 { exceeded, stderr: err, stdout: out };
|
|
}
|
|
|
|
function truncatePreview(text: string, maxLen: number): string {
|
|
if (text.length <= maxLen) return text;
|
|
return text.slice(0, maxLen);
|
|
}
|