1
0

feat: 重构配置生命周期为 Authoring/Normalized/Resolved 三层

将变量替换和 expect 简写展开统一放入 Normalized 阶段,
运行时 AJV 使用 Normalized schema,导出 schema 面向 Authoring Config。

主要变更:
- 新增 normalizer.ts 实现 normalizeAuthoringConfig()
- 拆分 Authoring/Normalized 双 schema,checker 接口支持 authoring/normalized 片段
- config-loader 流程:normalize → Normalized AJV → semantic → resolve
- validator 兼容层自动分派 raw/normalized expect 形态
- 删除 rawExpect,store.expect 列写入 null
- Authoring schema 对 integer/boolean/enum 字段接受变量引用
- 修复 DB/HTTP validate 入口守卫和 LLM options integer 变量引用
- 优化 compact() 避免 undefined 覆盖隐患
- 移除 content.ts 恒为 true 的前置条件
- 同步 5 个主规范并归档 change
This commit is contained in:
2026-05-22 14:00:47 +08:00
parent 6e53c8130d
commit cf847ccd7a
56 changed files with 1717 additions and 656 deletions

View File

@@ -13,12 +13,12 @@ import type {
ServerStorageConfig,
} from "./types";
import { normalizeAuthoringConfig } from "./normalizer";
import { checkerRegistry } from "./runner";
import { issue, throwConfigIssues } from "./schema/issues";
import { asValidatedConfig, type RawProbeConfig } from "./schema/types";
import { asValidatedConfig, type NormalizedProbeConfig } from "./schema/types";
import { validateProbeConfigContract } from "./schema/validate";
import { parseDuration, parseSize } from "./utils";
import { resolveVariables } from "./variables";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
@@ -60,17 +60,17 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
throw new Error("配置文件内容为空或格式无效");
}
const variableResult = resolveVariables(parsed);
if (variableResult.issues.length > 0) {
throwConfigIssues(dedupeIssues(variableResult.issues));
const normalizeResult = normalizeAuthoringConfig(parsed);
if (normalizeResult.issues.length > 0) {
throwConfigIssues(dedupeIssues(normalizeResult.issues));
}
const resolvedVariablesConfig = variableResult.config;
const contractResult = validateProbeConfigContract(resolvedVariablesConfig, checkerRegistry);
if (contractResult.config === null && !canRunSemanticValidation(resolvedVariablesConfig)) {
const normalizedConfig = normalizeResult.config;
const contractResult = validateProbeConfigContract(normalizedConfig, checkerRegistry);
if (contractResult.config === null && !canRunSemanticValidation(normalizedConfig)) {
throwConfigIssues(contractResult.issues);
}
const semanticInput = (contractResult.config ?? resolvedVariablesConfig) as RawProbeConfig;
const semanticInput = (contractResult.config ?? normalizedConfig) as NormalizedProbeConfig;
const validationIssues = validateConfig(semanticInput);
const allIssues = [...contractResult.issues, ...validationIssues];
@@ -208,7 +208,7 @@ function resolveTarget(
return result;
}
function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
function validateConfig(config: NormalizedProbeConfig): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
if (!Array.isArray(config.targets) || config.targets.length === 0) {
issues.push(issue("required", "targets", "配置文件必须包含至少一个 target"));

View File

@@ -22,7 +22,7 @@ import type {
} from "./types";
import { errorFailure, mismatchFailure } from "./failure";
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./keys";
import { MATCHER_KEY_SET } from "./keys";
import { applyValueMatcher, displayValueExpectation, evaluateJsonPath } from "./value";
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
@@ -238,7 +238,7 @@ function resolveContentExpectation(raw: RawContentExpectation): ContentExpectati
}
const record = raw as Record<string, unknown>;
if (CONTENT_EXTRACTOR_KEY_SET.has("json") && isPlainObject(record["json"])) {
if (isPlainObject(record["json"])) {
const json = record["json"] as RawContentJsonExpectation;
return {
kind: "json",

View File

@@ -58,6 +58,7 @@ export function validateRawKeyedExpectations(
targetName?: string,
options?: { caseInsensitive?: boolean },
): ConfigValidationIssue[] {
if (Array.isArray(value)) return validateNormalizedKeyedExpectations(value, path, targetName, options);
if (!isPlainRecord(value)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
@@ -196,12 +197,76 @@ function validateMatcherValue(key: string, value: unknown, path: string, targetN
}
}
function validateNormalizedContentExpectation(
expectation: Record<string, unknown>,
path: string,
targetName?: string,
): ConfigValidationIssue[] {
const kind = expectation["kind"];
const matcherPath = joinPath(path, "matcher");
const issues = validateRawValueExpectation(expectation["matcher"], matcherPath, targetName);
switch (kind) {
case "css":
if (!isString(expectation["selector"]) || expectation["selector"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
}
if ("attr" in expectation && !isString(expectation["attr"])) {
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
}
return issues;
case "json":
return isString(expectation["path"])
? [...issues, ...validateJsonPath(expectation["path"], path, targetName)]
: [...issues, issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName)];
case "value":
return issues;
case "xpath":
return isString(expectation["path"])
? [...issues, ...validateXpathExpectation({ path: expectation["path"] }, path, targetName)]
: [...issues, issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName)];
default:
return [...issues, issue("invalid-type", joinPath(path, "kind"), "必须为 value、json、css 或 xpath", targetName)];
}
}
function validateNormalizedKeyedExpectations(
value: unknown[],
path: string,
targetName?: string,
options?: { caseInsensitive?: boolean },
): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const seen = new Map<string, string>();
for (let i = 0; i < value.length; i++) {
const itemPath = `${path}[${i}]`;
const item = value[i];
if (!isPlainRecord(item)) {
issues.push(issue("invalid-type", itemPath, "必须为对象", targetName));
continue;
}
if (!isString(item["key"])) {
issues.push(issue("invalid-type", joinPath(itemPath, "key"), "必须为字符串", targetName));
} else if (options?.caseInsensitive) {
const normalized = item["key"].toLowerCase();
const prev = seen.get(normalized);
if (prev !== undefined) {
issues.push(issue("duplicate-key", joinPath(itemPath, "key"), `与 "${prev}" 大小写归一化后重复`, targetName));
} else {
seen.set(normalized, item["key"]);
}
}
issues.push(...validateRawValueExpectation(item["matcher"], joinPath(itemPath, "matcher"), targetName));
}
return issues;
}
function validateRawContentExpectation(
expectation: unknown,
path: string,
targetName?: string,
): ConfigValidationIssue[] {
if (!isPlainRecord(expectation)) return [issue("invalid-type", path, "必须为对象", targetName)];
if (isString(expectation["kind"])) return validateNormalizedContentExpectation(expectation, path, targetName);
const issues: ConfigValidationIssue[] = [];
const extractors = Object.keys(expectation).filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key));

View File

@@ -0,0 +1,187 @@
import { isPlainObject } from "es-toolkit";
import type { ConfigValidationIssue } from "./schema/issues";
import type { AuthoringProbeConfig, NormalizedProbeConfig } from "./schema/types";
import type { RawTargetConfig } from "./types";
import { resolveContentExpectations } from "./expect/content";
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./expect/keys";
import { isValueMatcherObject, isValueMatcherPrimitive, resolveValueExpectation } from "./expect/value";
import { resolveVariables } from "./variables";
type ExpectRecord = Record<string, unknown>;
export function normalizeAuthoringConfig(config: unknown): {
config: unknown;
issues: ConfigValidationIssue[];
} {
const variableResult = resolveVariables(config);
if (!isPlainObject(variableResult.config)) {
return variableResult;
}
const normalized = { ...(variableResult.config as Record<string, unknown>) };
delete normalized["variables"];
if (Array.isArray(normalized["targets"])) {
normalized["targets"] = normalized["targets"].map((target) => normalizeTarget(target));
}
return { config: normalized, issues: variableResult.issues };
}
function canNormalizeContentEntry(value: unknown): boolean {
if (!isPlainObject(value)) return false;
const keys = Object.keys(value);
const extractorKeys = keys.filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key));
const matcherKeys = keys.filter((key) => MATCHER_KEY_SET.has(key));
if (extractorKeys.length === 0) return matcherKeys.length > 0 && matcherKeys.length === keys.length;
if (extractorKeys.length !== 1 || matcherKeys.length > 0 || keys.length !== 1) return false;
return isPlainObject((value as ExpectRecord)[extractorKeys[0]!]);
}
function compact(original: ExpectRecord, overrides: ExpectRecord): ExpectRecord {
const result: ExpectRecord = {};
for (const [key, value] of Object.entries(original)) {
if (value !== undefined) result[key] = value;
}
for (const [key, value] of Object.entries(overrides)) {
if (value !== undefined) result[key] = value;
}
return result;
}
function normalizeCommandExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
durationMs: normalizeValue(raw["durationMs"]),
exitCode: raw["exitCode"],
stderr: normalizeContent(raw["stderr"]),
stdout: normalizeContent(raw["stdout"]),
});
}
function normalizeContent(value: unknown): unknown {
if (value === undefined) return undefined;
if (!Array.isArray(value)) return value;
return (value as unknown[]).map((entry): unknown => {
if (!canNormalizeContentEntry(entry)) return entry;
const resolved = resolveContentExpectations([entry] as never);
return resolved?.[0];
});
}
function normalizeDbExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
durationMs: normalizeValue(raw["durationMs"]),
result: normalizeContent(raw["result"]),
rowCount: normalizeValue(raw["rowCount"]),
rows: Array.isArray(raw["rows"]) ? raw["rows"].map((row) => normalizeKeyed(row)) : raw["rows"],
});
}
function normalizeExpect(type: string, expect: unknown): unknown {
if (!isPlainObject(expect)) return expect;
const raw = expect as ExpectRecord;
switch (type) {
case "cmd":
return normalizeCommandExpect(raw);
case "db":
return normalizeDbExpect(raw);
case "http":
return normalizeHttpExpect(raw);
case "icmp":
return normalizeIcmpExpect(raw);
case "llm":
return normalizeLlmExpect(raw);
case "tcp":
return normalizeTcpExpect(raw);
case "udp":
return normalizeUdpExpect(raw);
default:
return expect;
}
}
function normalizeHttpExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
body: normalizeContent(raw["body"]),
durationMs: normalizeValue(raw["durationMs"]),
headers: normalizeKeyed(raw["headers"]),
status: raw["status"],
});
}
function normalizeIcmpExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
alive: raw["alive"],
avgLatencyMs: normalizeValue(raw["avgLatencyMs"]),
durationMs: normalizeValue(raw["durationMs"]),
maxLatencyMs: normalizeValue(raw["maxLatencyMs"]),
packetLossPercent: normalizeValue(raw["packetLossPercent"]),
});
}
function normalizeKeyed(value: unknown): unknown {
if (value === undefined) return undefined;
if (!isPlainObject(value)) return value;
return Object.entries(value as ExpectRecord).map(([key, item]) => ({ key, matcher: normalizeValue(item) }));
}
function normalizeLlmExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
durationMs: normalizeValue(raw["durationMs"]),
finishReason: normalizeValue(raw["finishReason"]),
headers: normalizeKeyed(raw["headers"]),
output: normalizeContent(raw["output"]),
rawFinishReason: normalizeValue(raw["rawFinishReason"]),
status: raw["status"],
stream: isPlainObject(raw["stream"])
? compact(raw["stream"] as ExpectRecord, {
completed: (raw["stream"] as ExpectRecord)["completed"],
firstTokenMs: normalizeValue((raw["stream"] as ExpectRecord)["firstTokenMs"]),
})
: raw["stream"],
usage: isPlainObject(raw["usage"])
? compact(raw["usage"] as ExpectRecord, {
inputTokens: normalizeValue((raw["usage"] as ExpectRecord)["inputTokens"]),
outputTokens: normalizeValue((raw["usage"] as ExpectRecord)["outputTokens"]),
totalTokens: normalizeValue((raw["usage"] as ExpectRecord)["totalTokens"]),
})
: raw["usage"],
});
}
function normalizeTarget(target: unknown): unknown {
if (!isPlainObject(target)) return target;
const result = { ...(target as RawTargetConfig) };
if (result.expect !== undefined) {
result.expect = normalizeExpect(result.type, result.expect);
}
return result;
}
function normalizeTcpExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
banner: normalizeContent(raw["banner"]),
connected: raw["connected"],
durationMs: normalizeValue(raw["durationMs"]),
});
}
function normalizeUdpExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
durationMs: normalizeValue(raw["durationMs"]),
responded: raw["responded"],
response: normalizeContent(raw["response"]),
responseSize: normalizeValue(raw["responseSize"]),
sourceHost: normalizeValue(raw["sourceHost"]),
sourcePort: normalizeValue(raw["sourcePort"]),
});
}
function normalizeValue(value: unknown): unknown {
if (value === undefined) return undefined;
if (isValueMatcherPrimitive(value) || isValueMatcherObject(value)) return resolveValueExpectation(value);
return value;
}
export type { AuthoringProbeConfig, NormalizedProbeConfig };

View File

@@ -3,16 +3,11 @@ 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 type { CommandTargetConfig, ResolvedCommandExpectConfig, ResolvedCommandTarget } from "./types";
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content";
import { checkContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { checkExitCode } from "./expect";
import { commandCheckerSchemas } from "./schema";
@@ -217,13 +212,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
const env = { ...process.env, ...(t.cmd.env ?? {}) } as Record<string, string>;
const rawExpect = target.expect as RawCommandExpectConfig | undefined;
const resolvedExpect: ResolvedCommandExpectConfig = rawExpect
const expect = target.expect as ResolvedCommandExpectConfig | undefined;
const resolvedExpect: ResolvedCommandExpectConfig = expect
? {
durationMs: resolveValueExpectation(rawExpect.durationMs),
exitCode: rawExpect.exitCode ?? [0],
stderr: resolveContentExpectations(rawExpect.stderr),
stdout: resolveContentExpectations(rawExpect.stdout),
...expect,
exitCode: expect.exitCode ?? [0],
}
: { exitCode: [0] };
@@ -241,7 +234,6 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs,
type: "cmd",
} satisfies ResolvedCommandTarget;

View File

@@ -3,14 +3,27 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createRawContentExpectationsSchema,
createRawValueExpectationSchema,
createAuthoringContentExpectationsSchema,
createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedValueExpectationSchema,
sizeSchema,
stringMapSchema,
} from "../../schema/fragments";
export const commandCheckerSchemas: CheckerSchemas = {
config: Type.Object(
authoring: {
config: createCommandConfigSchema(),
expect: createCommandExpectSchema("authoring"),
},
normalized: {
config: createCommandConfigSchema(),
expect: createCommandExpectSchema("normalized"),
},
};
function createCommandConfigSchema() {
return Type.Object(
{
args: Type.Optional(Type.Array(Type.String())),
cwd: Type.Optional(Type.String()),
@@ -19,14 +32,23 @@ export const commandCheckerSchemas: CheckerSchemas = {
maxOutputBytes: Type.Optional(sizeSchema),
},
{ additionalProperties: false },
),
expect: Type.Object(
);
}
function createCommandExpectSchema(kind: "authoring" | "normalized") {
return Type.Object(
{
durationMs: Type.Optional(createRawValueExpectationSchema()),
durationMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
exitCode: Type.Optional(Type.Array(Type.Integer())),
stderr: Type.Optional(createRawContentExpectationsSchema()),
stdout: Type.Optional(createRawContentExpectationsSchema()),
stderr: Type.Optional(
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
),
stdout: Type.Optional(
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
),
},
{ additionalProperties: false },
),
};
);
}

View File

@@ -42,7 +42,6 @@ export interface ResolvedCommandTarget extends ResolvedTargetBase {
group: string;
intervalMs: number;
name: null | string;
rawExpect?: RawCommandExpectConfig;
timeoutMs: number;
type: "cmd";
}

View File

@@ -3,12 +3,11 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { DbTargetConfig, RawDbExpectConfig, ResolvedDbExpectConfig, ResolvedDbTarget } from "./types";
import type { DbTargetConfig, ResolvedDbExpectConfig, ResolvedDbTarget } from "./types";
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content";
import { checkContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure";
import { resolveKeyedExpectations } from "../../expect/keyed";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
import { checkValueExpectation } from "../../expect/value";
import { checkRowCount, checkRows } from "./expect";
import { dbCheckerSchemas } from "./schema";
import { validateDbConfig } from "./validate";
@@ -227,15 +226,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDbTarget {
const t = target as RawTargetConfig & { db: DbTargetConfig; type: "db" };
const rawExpect = target.expect as RawDbExpectConfig | undefined;
const resolvedExpect: ResolvedDbExpectConfig | undefined = rawExpect
? {
durationMs: resolveValueExpectation(rawExpect.durationMs),
result: resolveContentExpectations(rawExpect.result),
rowCount: resolveValueExpectation(rawExpect.rowCount),
rows: rawExpect.rows?.map((r) => resolveKeyedExpectations(r)!),
}
: undefined;
const resolvedExpect = target.expect as ResolvedDbExpectConfig | undefined;
return {
db: {
@@ -248,7 +239,6 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs,
type: "db",
} satisfies ResolvedDbTarget;

View File

@@ -3,13 +3,27 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createRawContentExpectationsSchema,
createRawKeyedExpectationsSchema,
createRawValueExpectationSchema,
createAuthoringContentExpectationsSchema,
createAuthoringKeyedExpectationsSchema,
createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedKeyedExpectationsSchema,
createNormalizedValueExpectationSchema,
} from "../../schema/fragments";
export const dbCheckerSchemas: CheckerSchemas = {
config: Type.Object(
authoring: {
config: createDbConfigSchema(),
expect: createDbExpectSchema("authoring"),
},
normalized: {
config: createDbConfigSchema(),
expect: createDbExpectSchema("normalized"),
},
};
function createDbConfigSchema() {
return Type.Object(
{
query: Type.Optional(
Type.String({
@@ -19,14 +33,27 @@ export const dbCheckerSchemas: CheckerSchemas = {
url: Type.String({ minLength: 1 }),
},
{ additionalProperties: false },
),
expect: Type.Object(
);
}
function createDbExpectSchema(kind: "authoring" | "normalized") {
return Type.Object(
{
durationMs: Type.Optional(createRawValueExpectationSchema()),
result: Type.Optional(createRawContentExpectationsSchema()),
rowCount: Type.Optional(createRawValueExpectationSchema()),
rows: Type.Optional(Type.Array(createRawKeyedExpectationsSchema())),
durationMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
result: Type.Optional(
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
),
rowCount: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
rows: Type.Optional(
Type.Array(
kind === "authoring" ? createAuthoringKeyedExpectationsSchema() : createNormalizedKeyedExpectationsSchema(),
),
),
},
{ additionalProperties: false },
),
};
);
}

View File

@@ -38,7 +38,6 @@ export interface ResolvedDbTarget extends ResolvedTargetBase {
group: string;
intervalMs: number;
name: null | string;
rawExpect?: RawDbExpectConfig;
timeoutMs: number;
type: "db";
}

View File

@@ -27,12 +27,7 @@ export function validateDbConfig(input: CheckerValidationInput): ConfigValidatio
function collectRowExpects(rows: unknown[], path: string, targetName?: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
if (!isPlainRecord(row)) {
issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName));
continue;
}
issues.push(...validateRawKeyedExpectations(row, `${path}[${i}]`, targetName));
issues.push(...validateRawKeyedExpectations(rows[i], `${path}[${i}]`, targetName));
}
return issues;
}

View File

@@ -2,14 +2,13 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { HttpTargetConfig, RawHttpExpectConfig, ResolvedHttpExpectConfig, ResolvedHttpTarget } from "./types";
import type { HttpTargetConfig, ResolvedHttpExpectConfig, ResolvedHttpTarget } from "./types";
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content";
import { checkContentExpectations } from "../../expect/content";
import { errorFailure, mismatchFailure } from "../../expect/failure";
import { checkHeaderExpectations } from "../../expect/headers";
import { resolveKeyedExpectations } from "../../expect/keyed";
import { checkStatusCode } from "../../expect/status";
import { checkValueExpectation, displayValueExpectation, resolveValueExpectation } from "../../expect/value";
import { checkValueExpectation, displayValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { httpCheckerSchemas } from "./schema";
import { validateHttpConfig } from "./validate";
@@ -179,13 +178,11 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
const method = t.http.method ?? "GET";
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? "100MB");
const rawExpect = target.expect as RawHttpExpectConfig | undefined;
const resolvedExpect: ResolvedHttpExpectConfig = rawExpect
const expect = target.expect as ResolvedHttpExpectConfig | undefined;
const resolvedExpect: ResolvedHttpExpectConfig = expect
? {
body: resolveContentExpectations(rawExpect.body),
durationMs: resolveValueExpectation(rawExpect.durationMs),
headers: resolveKeyedExpectations(rawExpect.headers),
status: rawExpect.status ?? [200],
...expect,
status: expect.status ?? [200],
}
: { status: [200] };
@@ -205,7 +202,6 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs,
type: "http",
} satisfies ResolvedHttpTarget;

View File

@@ -3,9 +3,14 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createRawContentExpectationsSchema,
createRawKeyedExpectationsSchema,
createRawValueExpectationSchema,
createAuthoringContentExpectationsSchema,
createAuthoringFieldSchema,
createAuthoringKeyedExpectationsSchema,
createAuthoringStringMapSchema,
createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedKeyedExpectationsSchema,
createNormalizedValueExpectationSchema,
httpMethodSchema,
sizeSchema,
statusCodePatternSchema,
@@ -13,25 +18,47 @@ import {
} from "../../schema/fragments";
export const httpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
authoring: {
config: createHttpConfigSchema("authoring"),
expect: createHttpExpectSchema("authoring"),
},
normalized: {
config: createHttpConfigSchema("normalized"),
expect: createHttpExpectSchema("normalized"),
},
};
function createHttpConfigSchema(kind: "authoring" | "normalized") {
const bool = Type.Boolean();
const redirects = Type.Integer({ minimum: 0 });
return Type.Object(
{
body: Type.Optional(Type.String()),
headers: Type.Optional(stringMapSchema),
ignoreSSL: Type.Optional(Type.Boolean()),
headers: Type.Optional(kind === "authoring" ? createAuthoringStringMapSchema() : stringMapSchema),
ignoreSSL: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
maxBodyBytes: Type.Optional(sizeSchema),
maxRedirects: Type.Optional(Type.Integer({ minimum: 0 })),
method: Type.Optional(httpMethodSchema),
maxRedirects: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(redirects) : redirects),
method: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(httpMethodSchema) : httpMethodSchema),
url: Type.String({ minLength: 1 }),
},
{ additionalProperties: false },
),
expect: Type.Object(
);
}
function createHttpExpectSchema(kind: "authoring" | "normalized") {
return Type.Object(
{
body: Type.Optional(createRawContentExpectationsSchema()),
durationMs: Type.Optional(createRawValueExpectationSchema()),
headers: Type.Optional(createRawKeyedExpectationsSchema()),
body: Type.Optional(
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
),
durationMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
headers: Type.Optional(
kind === "authoring" ? createAuthoringKeyedExpectationsSchema() : createNormalizedKeyedExpectationsSchema(),
),
status: Type.Optional(Type.Array(statusCodePatternSchema)),
},
{ additionalProperties: false },
),
};
);
}

View File

@@ -48,7 +48,6 @@ export interface ResolvedHttpTarget extends ResolvedTargetBase {
http: ResolvedHttpConfig;
intervalMs: number;
name: null | string;
rawExpect?: RawHttpExpectConfig;
timeoutMs: number;
type: "http";
}

View File

@@ -43,7 +43,7 @@ function validateHttpExpect(target: Record<string, unknown>, path: string): Conf
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (isPlainRecord(expect["headers"])) {
if (expect["headers"] !== undefined) {
issues.push(
...validateRawKeyedExpectations(expect["headers"], joinPath(expectPath, "headers"), targetName, {
caseInsensitive: true,

View File

@@ -2,16 +2,10 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type {
PingStats,
PingTargetConfig,
RawIcmpExpectConfig,
ResolvedIcmpExpectConfig,
ResolvedPingTarget,
} from "./types";
import type { PingStats, PingTargetConfig, ResolvedIcmpExpectConfig, ResolvedPingTarget } from "./types";
import { errorFailure } from "../../expect/failure";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
import { checkValueExpectation } from "../../expect/value";
import { buildPingCommand } from "./command";
import { checkAlive, checkAvgLatency, checkMaxLatency, checkPacketLoss } from "./expect";
import { parsePingOutput } from "./parse";
@@ -162,14 +156,11 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedPingTarget {
const t = target as RawTargetConfig & { icmp: PingTargetConfig; type: "icmp" };
const rawExpect = target.expect as RawIcmpExpectConfig | undefined;
const resolvedExpect: ResolvedIcmpExpectConfig = rawExpect
const expect = target.expect as ResolvedIcmpExpectConfig | undefined;
const resolvedExpect: ResolvedIcmpExpectConfig = expect
? {
alive: rawExpect.alive ?? true,
avgLatencyMs: resolveValueExpectation(rawExpect.avgLatencyMs),
durationMs: resolveValueExpectation(rawExpect.durationMs),
maxLatencyMs: resolveValueExpectation(rawExpect.maxLatencyMs),
packetLossPercent: resolveValueExpectation(rawExpect.packetLossPercent),
...expect,
alive: expect.alive ?? true,
}
: { alive: true };
@@ -185,7 +176,6 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs,
type: "icmp",
} satisfies ResolvedPingTarget;

View File

@@ -2,25 +2,54 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createRawValueExpectationSchema } from "../../schema/fragments";
import {
createAuthoringFieldSchema,
createAuthoringValueExpectationSchema,
createNormalizedValueExpectationSchema,
} from "../../schema/fragments";
export const icmpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
{
count: Type.Optional(Type.Integer({ maximum: 100, minimum: 1 })),
host: Type.String({ minLength: 1 }),
packetSize: Type.Optional(Type.Integer({ maximum: 65500, minimum: 1 })),
},
{ additionalProperties: false },
),
expect: Type.Object(
{
alive: Type.Optional(Type.Boolean()),
avgLatencyMs: Type.Optional(createRawValueExpectationSchema()),
durationMs: Type.Optional(createRawValueExpectationSchema()),
maxLatencyMs: Type.Optional(createRawValueExpectationSchema()),
packetLossPercent: Type.Optional(createRawValueExpectationSchema()),
},
{ additionalProperties: false },
),
authoring: {
config: createIcmpConfigSchema("authoring"),
expect: createIcmpExpectSchema("authoring"),
},
normalized: {
config: createIcmpConfigSchema("normalized"),
expect: createIcmpExpectSchema("normalized"),
},
};
function createIcmpConfigSchema(kind: "authoring" | "normalized") {
const count = Type.Integer({ maximum: 100, minimum: 1 });
const packetSize = Type.Integer({ maximum: 65500, minimum: 1 });
return Type.Object(
{
count: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(count) : count),
host: Type.String({ minLength: 1 }),
packetSize: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(packetSize) : packetSize),
},
{ additionalProperties: false },
);
}
function createIcmpExpectSchema(kind: "authoring" | "normalized") {
const bool = Type.Boolean();
return Type.Object(
{
alive: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
avgLatencyMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
durationMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
maxLatencyMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
packetLossPercent: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
},
{ additionalProperties: false },
);
}

View File

@@ -45,7 +45,6 @@ export interface ResolvedPingTarget extends ResolvedTargetBase {
icmp: ResolvedPingConfig;
intervalMs: number;
name: null | string;
rawExpect?: RawIcmpExpectConfig;
timeoutMs: number;
type: "icmp";
}

View File

@@ -3,12 +3,10 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { LlmTargetConfig, RawLlmExpectConfig, ResolvedLlmExpectConfig, ResolvedLlmTarget } from "./types";
import type { LlmTargetConfig, ResolvedLlmExpectConfig, ResolvedLlmTarget } from "./types";
import { resolveContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure";
import { resolveKeyedExpectations } from "../../expect/keyed";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
import { checkValueExpectation } from "../../expect/value";
import { runExpects } from "./expect";
import {
buildObservationFromApiCallError,
@@ -155,26 +153,15 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
url: t.llm.url,
};
const rawExpect = target.expect as RawLlmExpectConfig | undefined;
const resolvedExpect: ResolvedLlmExpectConfig = rawExpect
const expect = target.expect as ResolvedLlmExpectConfig | undefined;
const resolvedExpect: ResolvedLlmExpectConfig = expect
? {
durationMs: resolveValueExpectation(rawExpect.durationMs),
finishReason: resolveValueExpectation(rawExpect.finishReason),
headers: resolveKeyedExpectations(rawExpect.headers),
output: resolveContentExpectations(rawExpect.output),
rawFinishReason: resolveValueExpectation(rawExpect.rawFinishReason),
status: rawExpect.status ?? [200],
stream: rawExpect.stream
...expect,
status: expect.status ?? [200],
stream: expect.stream
? {
completed: rawExpect.stream.completed ?? true,
firstTokenMs: resolveValueExpectation(rawExpect.stream.firstTokenMs),
}
: undefined,
usage: rawExpect.usage
? {
inputTokens: resolveValueExpectation(rawExpect.usage.inputTokens),
outputTokens: resolveValueExpectation(rawExpect.usage.outputTokens),
totalTokens: resolveValueExpectation(rawExpect.usage.totalTokens),
...expect.stream,
completed: expect.stream.completed ?? true,
}
: undefined,
}
@@ -188,7 +175,6 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
intervalMs: context.defaultIntervalMs,
llm: resolvedConfig,
name: (target.name as null | string) ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs,
type: "llm",
} satisfies ResolvedLlmTarget;

View File

@@ -3,18 +3,26 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createRawContentExpectationsSchema,
createRawKeyedExpectationsSchema,
createRawValueExpectationSchema,
createAuthoringContentExpectationsSchema,
createAuthoringFieldSchema,
createAuthoringKeyedExpectationsSchema,
createAuthoringStringMapSchema,
createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedKeyedExpectationsSchema,
createNormalizedValueExpectationSchema,
statusCodePatternSchema,
stringMapSchema,
} from "../../schema/fragments";
function createLlmOptionsSchema() {
function createLlmOptionsSchema(kind: "authoring" | "normalized") {
const maxOutputTokens = Type.Integer({ minimum: 1 });
return Type.Object(
{
frequencyPenalty: Type.Optional(Type.Number()),
maxOutputTokens: Type.Optional(Type.Integer({ minimum: 1 })),
maxOutputTokens: Type.Optional(
kind === "authoring" ? createAuthoringFieldSchema(maxOutputTokens) : maxOutputTokens,
),
presencePenalty: Type.Optional(Type.Number()),
seed: Type.Optional(Type.Number()),
stopSequences: Type.Optional(Type.Array(Type.String())),
@@ -27,35 +35,59 @@ function createLlmOptionsSchema() {
}
export const llmCheckerSchemas: CheckerSchemas = {
config: Type.Object(
authoring: {
config: createLlmConfigSchema("authoring"),
expect: createLlmExpectSchema("authoring"),
},
normalized: {
config: createLlmConfigSchema("normalized"),
expect: createLlmExpectSchema("normalized"),
},
};
function createLlmConfigSchema(kind: "authoring" | "normalized") {
const bool = Type.Boolean();
const mode = createLlmModeSchema();
const provider = createLlmProviderSchema();
return Type.Object(
{
authToken: Type.Optional(Type.String()),
headers: Type.Optional(stringMapSchema),
ignoreSSL: Type.Optional(Type.Boolean()),
headers: Type.Optional(kind === "authoring" ? createAuthoringStringMapSchema() : stringMapSchema),
ignoreSSL: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
key: Type.Optional(Type.String()),
mode: Type.Optional(Type.Union([Type.Literal("http"), Type.Literal("stream")])),
mode: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(mode) : mode),
model: Type.String({ minLength: 1 }),
options: Type.Optional(createLlmOptionsSchema()),
options: Type.Optional(createLlmOptionsSchema(kind)),
prompt: Type.String({ minLength: 1 }),
provider: Type.Union([Type.Literal("openai"), Type.Literal("openai-responses"), Type.Literal("anthropic")]),
provider: kind === "authoring" ? createAuthoringFieldSchema(provider) : provider,
providerOptions: Type.Optional(Type.Record(Type.String(), Type.Object({}, { additionalProperties: true }))),
url: Type.String({ minLength: 1 }),
},
{ additionalProperties: false },
),
expect: Type.Object(
);
}
function createLlmExpectSchema(kind: "authoring" | "normalized") {
const bool = Type.Boolean();
const valueExpectation =
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema();
return Type.Object(
{
durationMs: Type.Optional(createRawValueExpectationSchema()),
finishReason: Type.Optional(createRawValueExpectationSchema()),
headers: Type.Optional(createRawKeyedExpectationsSchema()),
output: Type.Optional(createRawContentExpectationsSchema()),
rawFinishReason: Type.Optional(createRawValueExpectationSchema()),
durationMs: Type.Optional(valueExpectation),
finishReason: Type.Optional(valueExpectation),
headers: Type.Optional(
kind === "authoring" ? createAuthoringKeyedExpectationsSchema() : createNormalizedKeyedExpectationsSchema(),
),
output: Type.Optional(
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
),
rawFinishReason: Type.Optional(valueExpectation),
status: Type.Optional(Type.Array(statusCodePatternSchema)),
stream: Type.Optional(
Type.Object(
{
completed: Type.Optional(Type.Boolean()),
firstTokenMs: Type.Optional(createRawValueExpectationSchema()),
completed: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
firstTokenMs: Type.Optional(valueExpectation),
},
{ additionalProperties: false },
),
@@ -63,14 +95,22 @@ export const llmCheckerSchemas: CheckerSchemas = {
usage: Type.Optional(
Type.Object(
{
inputTokens: Type.Optional(createRawValueExpectationSchema()),
outputTokens: Type.Optional(createRawValueExpectationSchema()),
totalTokens: Type.Optional(createRawValueExpectationSchema()),
inputTokens: Type.Optional(valueExpectation),
outputTokens: Type.Optional(valueExpectation),
totalTokens: Type.Optional(valueExpectation),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
),
};
);
}
function createLlmModeSchema() {
return Type.Union([Type.Literal("http"), Type.Literal("stream")]);
}
function createLlmProviderSchema() {
return Type.Union([Type.Literal("openai"), Type.Literal("openai-responses"), Type.Literal("anthropic")]);
}

View File

@@ -152,7 +152,6 @@ export interface ResolvedLlmTarget extends ResolvedTargetBase {
intervalMs: number;
llm: ResolvedLlmConfig;
name: null | string;
rawExpect?: RawLlmExpectConfig;
timeoutMs: number;
type: "llm";
}

View File

@@ -2,11 +2,10 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { RawTcpExpectConfig, ResolvedTcpExpectConfig, ResolvedTcpTarget, TcpTargetConfig } from "./types";
import type { ResolvedTcpExpectConfig, ResolvedTcpTarget, TcpTargetConfig } from "./types";
import { resolveContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { checkBanner, checkConnected } from "./expect";
import { tcpCheckerSchemas } from "./schema";
@@ -210,12 +209,11 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
const maxBannerBytes = parseSize(t.tcp.maxBannerBytes ?? DEFAULT_MAX_BANNER_BYTES);
const bannerReadTimeout = t.tcp.bannerReadTimeout ?? DEFAULT_BANNER_READ_TIMEOUT;
const rawExpect = target.expect as RawTcpExpectConfig | undefined;
const resolvedExpect: ResolvedTcpExpectConfig = rawExpect
const expect = target.expect as ResolvedTcpExpectConfig | undefined;
const resolvedExpect: ResolvedTcpExpectConfig = expect
? {
banner: resolveContentExpectations(rawExpect.banner),
connected: rawExpect.connected ?? true,
durationMs: resolveValueExpectation(rawExpect.durationMs),
...expect,
connected: expect.connected ?? true,
}
: { connected: true };
@@ -226,7 +224,6 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
rawExpect,
tcp: {
bannerReadTimeout,
host: t.tcp.host,

View File

@@ -3,28 +3,52 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createRawContentExpectationsSchema,
createRawValueExpectationSchema,
createAuthoringContentExpectationsSchema,
createAuthoringFieldSchema,
createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedValueExpectationSchema,
sizeSchema,
} from "../../schema/fragments";
export const tcpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
authoring: {
config: createTcpConfigSchema("authoring"),
expect: createTcpExpectSchema("authoring"),
},
normalized: {
config: createTcpConfigSchema("normalized"),
expect: createTcpExpectSchema("normalized"),
},
};
function createTcpConfigSchema(kind: "authoring" | "normalized") {
const port = Type.Integer({ maximum: 65535, minimum: 1 });
const readBanner = Type.Boolean();
return Type.Object(
{
bannerReadTimeout: Type.Optional(Type.Number({ minimum: 0 })),
host: Type.String({ minLength: 1 }),
maxBannerBytes: Type.Optional(sizeSchema),
port: Type.Integer({ maximum: 65535, minimum: 1 }),
readBanner: Type.Optional(Type.Boolean()),
port: kind === "authoring" ? createAuthoringFieldSchema(port) : port,
readBanner: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(readBanner) : readBanner),
},
{ additionalProperties: false },
),
expect: Type.Object(
);
}
function createTcpExpectSchema(kind: "authoring" | "normalized") {
const connected = Type.Boolean();
return Type.Object(
{
banner: Type.Optional(createRawContentExpectationsSchema()),
connected: Type.Optional(Type.Boolean()),
durationMs: Type.Optional(createRawValueExpectationSchema()),
banner: Type.Optional(
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
),
connected: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(connected) : connected),
durationMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
},
{ additionalProperties: false },
),
};
);
}

View File

@@ -31,7 +31,6 @@ export interface ResolvedTcpTarget extends ResolvedTargetBase {
group: string;
intervalMs: number;
name: null | string;
rawExpect?: RawTcpExpectConfig;
tcp: ResolvedTcpConfig;
timeoutMs: number;
type: "tcp";

View File

@@ -20,11 +20,16 @@ export interface CheckerDefinition<TResolved extends ResolvedTargetBase = Resolv
validate(input: CheckerValidationInput): ConfigValidationIssue[];
}
export interface CheckerSchemas {
export interface CheckerSchemaPair {
config: TSchema;
expect: TSchema;
}
export interface CheckerSchemas {
authoring: CheckerSchemaPair;
normalized: CheckerSchemaPair;
}
export interface CheckerValidationInput {
targets: RawTargetConfig[];
}

View File

@@ -2,11 +2,10 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { RawUdpExpectConfig, ResolvedUdpExpectConfig, ResolvedUdpTarget, UdpTargetConfig } from "./types";
import type { ResolvedUdpExpectConfig, ResolvedUdpTarget, UdpTargetConfig } from "./types";
import { resolveContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure";
import { checkValueExpectation, resolveValueExpectation } from "../../expect/value";
import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { decodePayload, encodeResponse } from "./encoding";
import { checkResponded, checkResponseSize, checkResponseText, checkSourceHost, checkSourcePort } from "./expect";
@@ -303,15 +302,11 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
const responseEncoding = t.udp.responseEncoding ?? "text";
const maxResponseBytes = parseSize(t.udp.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES);
const rawExpect = target.expect as RawUdpExpectConfig | undefined;
const resolvedExpect: ResolvedUdpExpectConfig = rawExpect
const expect = target.expect as ResolvedUdpExpectConfig | undefined;
const resolvedExpect: ResolvedUdpExpectConfig = expect
? {
durationMs: resolveValueExpectation(rawExpect.durationMs),
responded: rawExpect.responded ?? true,
response: resolveContentExpectations(rawExpect.response),
responseSize: resolveValueExpectation(rawExpect.responseSize),
sourceHost: resolveValueExpectation(rawExpect.sourceHost),
sourcePort: resolveValueExpectation(rawExpect.sourcePort),
...expect,
responded: expect.responded ?? true,
}
: { responded: true };
@@ -322,7 +317,6 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs,
type: "udp",
udp: {

View File

@@ -3,32 +3,69 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createRawContentExpectationsSchema,
createRawValueExpectationSchema,
createAuthoringContentExpectationsSchema,
createAuthoringFieldSchema,
createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedValueExpectationSchema,
sizeSchema,
} from "../../schema/fragments";
export const udpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
authoring: {
config: createUdpConfigSchema("authoring"),
expect: createUdpExpectSchema("authoring"),
},
normalized: {
config: createUdpConfigSchema("normalized"),
expect: createUdpExpectSchema("normalized"),
},
};
function createEncodingSchema() {
return Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")]);
}
function createUdpConfigSchema(kind: "authoring" | "normalized") {
const port = Type.Integer({ maximum: 65535, minimum: 1 });
const encoding = createEncodingSchema();
const responseEncoding = createEncodingSchema();
return Type.Object(
{
encoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
encoding: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(encoding) : encoding),
host: Type.String({ minLength: 1 }),
maxResponseBytes: Type.Optional(sizeSchema),
payload: Type.Optional(Type.String()),
port: Type.Integer({ maximum: 65535, minimum: 1 }),
responseEncoding: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("hex"), Type.Literal("base64")])),
port: kind === "authoring" ? createAuthoringFieldSchema(port) : port,
responseEncoding: Type.Optional(
kind === "authoring" ? createAuthoringFieldSchema(responseEncoding) : responseEncoding,
),
},
{ additionalProperties: false },
),
expect: Type.Object(
);
}
function createUdpExpectSchema(kind: "authoring" | "normalized") {
const responded = Type.Boolean();
return Type.Object(
{
durationMs: Type.Optional(createRawValueExpectationSchema()),
responded: Type.Optional(Type.Boolean()),
response: Type.Optional(createRawContentExpectationsSchema()),
responseSize: Type.Optional(createRawValueExpectationSchema()),
sourceHost: Type.Optional(createRawValueExpectationSchema()),
sourcePort: Type.Optional(createRawValueExpectationSchema()),
durationMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
responded: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(responded) : responded),
response: Type.Optional(
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
),
responseSize: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
sourceHost: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
sourcePort: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
},
{ additionalProperties: false },
),
};
);
}

View File

@@ -38,7 +38,6 @@ export interface ResolvedUdpTarget extends ResolvedTargetBase {
group: string;
intervalMs: number;
name: null | string;
rawExpect?: RawUdpExpectConfig;
timeoutMs: number;
type: "udp";
udp: ResolvedUdpConfig;

View File

@@ -5,9 +5,10 @@ import { Type } from "@sinclair/typebox";
import type { CheckerDefinition } from "../runner/types";
import {
createRawContentExpectationsSchema,
createRawKeyedExpectationsSchema,
createRawValueExpectationSchema,
createAuthoringContentExpectationsSchema,
createAuthoringFieldSchema,
createAuthoringKeyedExpectationsSchema,
createAuthoringValueExpectationSchema,
createValueMatcherObjectSchema,
durationSchema,
sizeSchema,
@@ -16,62 +17,58 @@ import {
const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] as const;
const ROTATION_FREQUENCIES = ["hourly", "daily", "weekly"] as const;
type SchemaKind = "authoring" | "normalized";
export function createAuthoringProbeConfigSchema(checkers: CheckerDefinition[], external = false): TSchema {
return createProbeConfigSchemaForKind(checkers, "authoring", external);
}
export function createAuthoringTargetSchema(checker: CheckerDefinition): TSchema {
return createTargetSchemaForKind(checker, "authoring");
}
export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record<string, unknown> {
return {
...cloneSchema(createProbeConfigSchema(checkers, true)),
...cloneSchema(createAuthoringProbeConfigSchema(checkers, true)),
$id: "https://dial.local/probe-config.schema.json",
$schema: "http://json-schema.org/draft-07/schema#",
definitions: {
ContentExpectations: cloneSchema(createRawContentExpectationsSchema()),
KeyedExpectations: cloneSchema(createRawKeyedExpectationsSchema()),
ValueExpectation: cloneSchema(createRawValueExpectationSchema()),
ContentExpectations: cloneSchema(createAuthoringContentExpectationsSchema()),
KeyedExpectations: cloneSchema(createAuthoringKeyedExpectationsSchema()),
ValueExpectation: cloneSchema(createAuthoringValueExpectationSchema()),
ValueMatcher: cloneSchema(createValueMatcherObjectSchema()),
},
};
}
export function createNormalizedProbeConfigSchema(checkers: CheckerDefinition[], external = false): TSchema {
return createProbeConfigSchemaForKind(checkers, "normalized", external);
}
export function createNormalizedTargetSchema(checker: CheckerDefinition): TSchema {
return createTargetSchemaForKind(checker, "normalized");
}
export function createProbeConfigSchema(checkers: CheckerDefinition[], external = false): TSchema {
return Type.Object(
{
probes: Type.Optional(createProbesSchema()),
server: Type.Optional(createServerSchema()),
targets: Type.Array(external ? createExternalTargetSchema(checkers) : createBaseTargetSchema(checkers), {
minItems: 1,
}),
variables: Type.Optional(Type.Record(Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" }), variableValueSchema)),
},
{ additionalProperties: false },
);
return createNormalizedProbeConfigSchema(checkers, external);
}
export function createTargetSchema(checker: CheckerDefinition): TSchema {
const properties: Record<string, TSchema> = {
description: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 500 })])),
expect: Type.Optional(checker.schemas.expect),
group: Type.Optional(Type.String()),
id: Type.String({ maxLength: 30, minLength: 1 }),
interval: Type.Optional(durationSchema),
name: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 30, minLength: 1 })])),
timeout: Type.Optional(durationSchema),
type: Type.Literal(checker.type),
};
properties[checker.configKey] = checker.schemas.config;
return Type.Object(properties, { additionalProperties: false });
return createNormalizedTargetSchema(checker);
}
function cloneSchema(schema: TSchema): Record<string, unknown> {
return JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
}
function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
function createBaseTargetSchema(checkers: CheckerDefinition[], kind: SchemaKind): TSchema {
return Type.Object(
{
description: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 500 })])),
description: Type.Optional(Type.Union([Type.Null(), stringForKind(kind, { maxLength: 500 })])),
group: Type.Optional(Type.String()),
id: Type.String({ maxLength: 30, minLength: 1 }),
interval: Type.Optional(durationSchema),
name: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 30, minLength: 1 })])),
name: Type.Optional(Type.Union([Type.Null(), stringForKind(kind, { maxLength: 30, minLength: 1 })])),
timeout: Type.Optional(durationSchema),
type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]),
},
@@ -79,27 +76,30 @@ function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
);
}
function createExternalTargetSchema(checkers: CheckerDefinition[]): TSchema {
return Type.Union(checkers.map((checker) => createTargetSchema(checker)) as [TSchema, ...TSchema[]]);
function createExternalTargetSchema(checkers: CheckerDefinition[], kind: SchemaKind): TSchema {
return Type.Union(checkers.map((checker) => createTargetSchemaForKind(checker, kind)) as [TSchema, ...TSchema[]]);
}
function createLoggingSchema(): TSchema {
function createLoggingSchema(kind: SchemaKind): TSchema {
const logLevelSchema = Type.Union(LOG_LEVELS.map((l) => Type.Literal(l)) as unknown as [TSchema, ...TSchema[]]);
const logLevel = enumForKind(kind, logLevelSchema);
const frequency = enumForKind(
kind,
Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]),
);
return Type.Object(
{
console: Type.Optional(Type.Object({ level: Type.Optional(logLevelSchema) }, { additionalProperties: false })),
console: Type.Optional(Type.Object({ level: Type.Optional(logLevel) }, { additionalProperties: false })),
file: Type.Optional(
Type.Object(
{
level: Type.Optional(logLevelSchema),
level: Type.Optional(logLevel),
path: Type.Optional(Type.String({ minLength: 1 })),
rotation: Type.Optional(
Type.Object(
{
frequency: Type.Optional(
Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]),
),
maxFiles: Type.Optional(Type.Integer({ minimum: 1 })),
frequency: Type.Optional(frequency),
maxFiles: Type.Optional(integerForKind(kind, { minimum: 1 })),
size: Type.Optional(sizeSchema),
},
{ additionalProperties: false },
@@ -109,19 +109,38 @@ function createLoggingSchema(): TSchema {
{ additionalProperties: false },
),
),
level: Type.Optional(logLevelSchema),
level: Type.Optional(logLevel),
},
{ additionalProperties: false },
);
}
function createProbesSchema(): TSchema {
function createProbeConfigSchemaForKind(checkers: CheckerDefinition[], kind: SchemaKind, external: boolean): TSchema {
const properties: Record<string, TSchema> = {
probes: Type.Optional(createProbesSchema(kind)),
server: Type.Optional(createServerSchema(kind)),
targets: Type.Array(
external ? createExternalTargetSchema(checkers, kind) : createBaseTargetSchema(checkers, kind),
{
minItems: 1,
},
),
};
if (kind === "authoring") {
properties["variables"] = Type.Optional(
Type.Record(Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" }), variableValueSchema),
);
}
return Type.Object(properties, { additionalProperties: false });
}
function createProbesSchema(kind: SchemaKind): TSchema {
return Type.Object(
{
execution: Type.Optional(
Type.Object(
{
maxConcurrentChecks: Type.Optional(Type.Integer({ minimum: 1 })),
maxConcurrentChecks: Type.Optional(integerForKind(kind, { minimum: 1 })),
},
{ additionalProperties: false },
),
@@ -131,19 +150,19 @@ function createProbesSchema(): TSchema {
);
}
function createServerSchema(): TSchema {
function createServerSchema(kind: SchemaKind): TSchema {
return Type.Object(
{
listen: Type.Optional(
Type.Object(
{
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Integer({ maximum: 65535, minimum: 0 })),
port: Type.Optional(integerForKind(kind, { maximum: 65535, minimum: 0 })),
},
{ additionalProperties: false },
),
),
logging: Type.Optional(createLoggingSchema()),
logging: Type.Optional(createLoggingSchema(kind)),
storage: Type.Optional(
Type.Object(
{
@@ -157,3 +176,32 @@ function createServerSchema(): TSchema {
{ additionalProperties: false },
);
}
function createTargetSchemaForKind(checker: CheckerDefinition, kind: SchemaKind): TSchema {
const properties: Record<string, TSchema> = {
description: Type.Optional(Type.Union([Type.Null(), stringForKind(kind, { maxLength: 500 })])),
expect: Type.Optional(checker.schemas[kind].expect),
group: Type.Optional(Type.String()),
id: Type.String({ maxLength: 30, minLength: 1 }),
interval: Type.Optional(durationSchema),
name: Type.Optional(Type.Union([Type.Null(), stringForKind(kind, { maxLength: 30, minLength: 1 })])),
timeout: Type.Optional(durationSchema),
type: Type.Literal(checker.type),
};
properties[checker.configKey] = checker.schemas[kind].config;
return Type.Object(properties, { additionalProperties: false });
}
function enumForKind(kind: SchemaKind, schema: TSchema): TSchema {
return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema;
}
function integerForKind(kind: SchemaKind, options?: Parameters<typeof Type.Integer>[0]): TSchema {
const schema = Type.Integer(options);
return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema;
}
function stringForKind(kind: SchemaKind, options?: Parameters<typeof Type.String>[0]): TSchema {
const schema = Type.String(options);
return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema;
}

View File

@@ -31,6 +31,8 @@ export const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 }
export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]);
export const variableReferenceSchema = Type.String({ pattern: "^\\$\\{[^}]+\\}$" });
export const statusCodePatternSchema = Type.Union([
Type.Integer({ maximum: 599, minimum: 100 }),
Type.String({ pattern: "^[1-5]xx$" }),
@@ -41,6 +43,81 @@ export const stringMapSchema = Type.Unsafe<Record<string, string>>({
type: "object",
});
export function createAuthoringContentExpectationsSchema(): TSchema {
return createRawContentExpectationsSchema();
}
export function createAuthoringFieldSchema(schema: TSchema): TSchema {
return Type.Unsafe({ anyOf: [schema, variableReferenceSchema] });
}
export function createAuthoringKeyedExpectationsSchema(): TSchema {
return createRawKeyedExpectationsSchema();
}
export function createAuthoringStringMapSchema(): TSchema {
return Type.Unsafe<Record<string, string>>({
additionalProperties: { anyOf: [{ type: "string" }, variableReferenceSchema] },
type: "object",
});
}
export function createAuthoringValueExpectationSchema(): TSchema {
return createRawValueExpectationSchema();
}
export function createNormalizedContentExpectationsSchema(): TSchema {
const valueExpectation = Type.Object(
{
kind: Type.Literal("value"),
matcher: createValueMatcherObjectSchema(),
},
{ additionalProperties: false },
);
const jsonExpectation = Type.Object(
{
kind: Type.Literal("json"),
matcher: createValueMatcherObjectSchema(),
path: Type.String(),
},
{ additionalProperties: false },
);
const cssExpectation = Type.Object(
{
attr: Type.Optional(Type.String()),
kind: Type.Literal("css"),
matcher: createValueMatcherObjectSchema(),
selector: Type.String({ minLength: 1 }),
},
{ additionalProperties: false },
);
const xpathExpectation = Type.Object(
{
kind: Type.Literal("xpath"),
matcher: createValueMatcherObjectSchema(),
path: Type.String({ minLength: 1 }),
},
{ additionalProperties: false },
);
return Type.Array(Type.Union([valueExpectation, jsonExpectation, cssExpectation, xpathExpectation]));
}
export function createNormalizedKeyedExpectationsSchema(): TSchema {
return Type.Array(
Type.Object(
{
key: Type.String(),
matcher: createValueMatcherObjectSchema(),
},
{ additionalProperties: false },
),
);
}
export function createNormalizedValueExpectationSchema(): TSchema {
return createValueMatcherObjectSchema();
}
export function createRawContentExpectationsSchema(): TSchema {
return Type.Array(
Type.Object(

View File

@@ -2,12 +2,16 @@ import type { ProbeConfig } from "../types";
declare const validatedConfigBrand: unique symbol;
export type AuthoringProbeConfig = ProbeConfig;
export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue };
export type RawProbeConfig = ProbeConfig;
export type NormalizedProbeConfig = Omit<ProbeConfig, "variables">;
export type ValidatedProbeConfig = RawProbeConfig & { readonly [validatedConfigBrand]: true };
export type RawProbeConfig = AuthoringProbeConfig;
export function asValidatedConfig(config: RawProbeConfig): ValidatedProbeConfig {
export type ValidatedProbeConfig = NormalizedProbeConfig & { readonly [validatedConfigBrand]: true };
export function asValidatedConfig(config: NormalizedProbeConfig): ValidatedProbeConfig {
return config as ValidatedProbeConfig;
}

View File

@@ -5,9 +5,9 @@ import { isPlainObject, isString } from "es-toolkit";
import type { CheckerRegistry } from "../runner/registry";
import type { ConfigValidationIssue } from "./issues";
import type { RawProbeConfig } from "./types";
import type { NormalizedProbeConfig } from "./types";
import { createProbeConfigSchema, createTargetSchema } from "./builder";
import { createNormalizedProbeConfigSchema, createNormalizedTargetSchema } from "./builder";
import { issue } from "./issues";
export function createConfigAjv(): Ajv {
@@ -21,11 +21,11 @@ export function issuesFromAjvErrors(errors: ErrorObject[], root: unknown, basePa
export function validateProbeConfigContract(
config: unknown,
registry: CheckerRegistry,
): { config: null; issues: ConfigValidationIssue[] } | { config: RawProbeConfig; issues: [] } {
): { config: NormalizedProbeConfig; issues: [] } | { config: null; issues: ConfigValidationIssue[] } {
const ajv = createConfigAjv();
const checkers = registry.definitions;
const issues: ConfigValidationIssue[] = [];
const rootValidate = ajv.compile(createProbeConfigSchema(checkers));
const rootValidate = ajv.compile(createNormalizedProbeConfigSchema(checkers));
if (!rootValidate(config)) {
issues.push(...issuesFromAjvErrors(rootValidate.errors ?? [], config));
}
@@ -34,7 +34,7 @@ export function validateProbeConfigContract(
const configRecord = config as Record<string, unknown>;
const targetsValue: unknown = configRecord["targets"];
if (!Array.isArray(targetsValue))
return issues.length > 0 ? { config: null, issues } : { config: config as RawProbeConfig, issues: [] };
return issues.length > 0 ? { config: null, issues } : { config: config as NormalizedProbeConfig, issues: [] };
const targets = targetsValue;
for (let i = 0; i < targets.length; i++) {
const target: unknown = targets[i];
@@ -44,14 +44,14 @@ export function validateProbeConfigContract(
if (!isString(targetType)) continue;
const checker = registry.tryGet(targetType);
if (!checker) continue;
const targetValidate = ajv.compile(createTargetSchema(checker));
const targetValidate = ajv.compile(createNormalizedTargetSchema(checker));
if (!targetValidate(target)) {
issues.push(...issuesFromAjvErrors(targetValidate.errors ?? [], config, `targets[${i}]`));
}
}
}
return issues.length > 0 ? { config: null, issues } : { config: config as RawProbeConfig, issues: [] };
return issues.length > 0 ? { config: null, issues } : { config: config as NormalizedProbeConfig, issues: [] };
}
function buildIssuePath(basePath: string, error: ErrorObject): string {

View File

@@ -352,7 +352,7 @@ export class ProbeStore {
const serialized = checkerRegistry.get(t.type).serialize(t);
const target = serialized.target;
const config = serialized.config;
const expect = t.rawExpect ? JSON.stringify(t.rawExpect) : null;
const expect = null;
if (existingIds.has(t.id)) {
updateStmt.run(t.name, t.description, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id);

View File

@@ -75,7 +75,6 @@ export interface ResolvedTargetBase {
id: string;
intervalMs: number;
name: null | string;
rawExpect?: unknown;
timeoutMs: number;
type: string;
}