The design argues the conformance suite is the specification and should be built before either implementation, since two hand-written implementations of one language diverge unless something shared pins them. This is that suite: 38 cases in /processing, a language-neutral top-level directory for the same reason /proto is one -- the Rust agent and the Go ingest tier both consume the definition and neither owns it. Nothing executes the cases, because neither implementation exists. A stdlib-only validator checks the corpus stays well-formed and runs in CI: known actions, addressable fields, compilable patterns, names matching filenames, and no case depending on record_id, which is withheld so the question of whether an ingest-side rule can see one stays open. The validator was checked against seven deliberately broken cases before being trusted, since "38/38 valid" means nothing from a validator that cannot fail. Writing the cases first has already paid for itself twice. It forced two determinism decisions the prose had left vague, both of which a conformance suite cannot avoid answering. Sampling is counter-based rather than random: random is statistically nicer and impossible to assert on. Windows are measured on record timestamps rather than wall-clock, which makes replay deterministic and, not incidentally, makes backfill behave correctly where a wall-clock window would not. And it made the missing aggregate_count answer concrete. The design does not say what that action emits, or what a query not expecting a synthetic record sees. Rather than invent one by writing cases, the validator rejects any case using it, so the design question has to be answered before the behaviour can be frozen by accident. The corpus includes the acceptance case from real measured data: the two processes that account for roughly 60% of a real workstation's journal volume, and the one kernel message worth keeping. Signed-off-by: John Coffey <[email protected]>
218 lines
8.4 KiB
Python
Executable File
218 lines
8.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Structural check for the processing conformance corpus.
|
|
|
|
The cases are the specification (see ../README.md), so they have to stay
|
|
well-formed even while neither implementation exists to execute them.
|
|
This validates shape only -- it does not run rules against records,
|
|
because there is nothing yet to run them with.
|
|
|
|
Stdlib only, on purpose: this must be runnable anywhere without a
|
|
dependency install, including from a bare CI container.
|
|
|
|
Exit 0 if every case is valid, 1 otherwise.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
CASES = os.path.join(HERE, "cases")
|
|
|
|
CASE_KEYS = {"name", "description", "rules", "inputs", "expect"}
|
|
RECORD_KEYS = {"timestamp_unix_nano", "host", "service", "severity",
|
|
"message", "attributes"}
|
|
|
|
SEVERITIES = {"SEVERITY_UNSPECIFIED", "SEVERITY_TRACE", "SEVERITY_DEBUG",
|
|
"SEVERITY_INFO", "SEVERITY_WARN", "SEVERITY_ERROR",
|
|
"SEVERITY_FATAL"}
|
|
|
|
OPS_WITH_VALUE = {"eq", "ne", "contains", "prefix", "suffix", "regex"}
|
|
OPS_WITHOUT_VALUE = {"exists", "not_exists"}
|
|
|
|
TOP_LEVEL_FIELDS = {"message", "host", "service", "severity"}
|
|
|
|
# action -> (required params, optional params)
|
|
ACTIONS = {
|
|
"drop": (set(), set()),
|
|
"drop_fields": ({"fields"}, set()),
|
|
"keep_fields": ({"fields"}, set()),
|
|
"rename": ({"from", "to"}, set()),
|
|
"derive": ({"field"}, {"value", "from_field"}),
|
|
"mask": ({"field", "pattern", "replacement"}, set()),
|
|
"parse_json": ({"field"}, {"prefix"}),
|
|
"parse_regex": ({"field", "pattern"}, set()),
|
|
"sample": ({"keep_one_in"}, set()),
|
|
"suppress_duplicates": ({"window_ms"}, {"key_fields"}),
|
|
}
|
|
|
|
# Deliberately absent from ACTIONS. The design does not say what it emits
|
|
# or what a query that is not expecting a synthetic record sees, so a
|
|
# case using it would invent that answer and freeze it by accident.
|
|
UNSPECIFIED_ACTIONS = {"aggregate_count"}
|
|
|
|
|
|
def valid_field(name):
|
|
return name in TOP_LEVEL_FIELDS or name.startswith("attributes.")
|
|
|
|
|
|
def check_record(rec, where, err):
|
|
if not isinstance(rec, dict):
|
|
return err(f"{where}: record must be an object")
|
|
missing = RECORD_KEYS - set(rec)
|
|
unknown = set(rec) - RECORD_KEYS
|
|
if missing:
|
|
err(f"{where}: record missing {sorted(missing)}")
|
|
if "record_id" in unknown:
|
|
err(f"{where}: record_id must never appear -- no case may depend "
|
|
f"on it (see ../README.md)")
|
|
if unknown - {"record_id"}:
|
|
err(f"{where}: record has unknown keys {sorted(unknown - {'record_id'})}")
|
|
if rec.get("severity") not in SEVERITIES:
|
|
err(f"{where}: unknown severity {rec.get('severity')!r}")
|
|
if not isinstance(rec.get("timestamp_unix_nano"), int):
|
|
err(f"{where}: timestamp_unix_nano must be an integer")
|
|
attrs = rec.get("attributes")
|
|
if not isinstance(attrs, dict):
|
|
err(f"{where}: attributes must be an object")
|
|
else:
|
|
for k, v in attrs.items():
|
|
if not isinstance(k, str) or not isinstance(v, str):
|
|
err(f"{where}: attributes must be string->string, got {k!r}={v!r}")
|
|
|
|
|
|
def check_action(a, where, err):
|
|
if not isinstance(a, dict) or "action" not in a:
|
|
return err(f"{where}: action must be an object with an 'action' key")
|
|
name = a["action"]
|
|
if name in UNSPECIFIED_ACTIONS:
|
|
return err(f"{where}: '{name}' is deliberately unspecified -- decide "
|
|
f"its output shape in the design doc before writing cases "
|
|
f"for it")
|
|
if name not in ACTIONS:
|
|
return err(f"{where}: unknown action {name!r}")
|
|
required, optional = ACTIONS[name]
|
|
given = set(a) - {"action"}
|
|
if required - given:
|
|
err(f"{where}: action {name!r} missing {sorted(required - given)}")
|
|
if given - required - optional:
|
|
err(f"{where}: action {name!r} has unknown params "
|
|
f"{sorted(given - required - optional)}")
|
|
if name == "derive" and not ({"value", "from_field"} & given):
|
|
err(f"{where}: derive needs exactly one of value/from_field")
|
|
if name == "derive" and {"value", "from_field"} <= given:
|
|
err(f"{where}: derive needs exactly one of value/from_field, not both")
|
|
for key in ("field", "from", "to"):
|
|
if key in a and not valid_field(a[key]):
|
|
err(f"{where}: {key}={a[key]!r} is not an addressable field")
|
|
for key in ("fields", "key_fields"):
|
|
for f in a.get(key, []):
|
|
if not valid_field(f):
|
|
err(f"{where}: {key} entry {f!r} is not an addressable field")
|
|
for key in ("pattern",):
|
|
if key in a:
|
|
try:
|
|
re.compile(a[key])
|
|
except re.error as exc:
|
|
err(f"{where}: {key} is not a valid regex: {exc}")
|
|
if name == "sample":
|
|
n = a.get("keep_one_in")
|
|
if not isinstance(n, int) or n < 1:
|
|
err(f"{where}: keep_one_in must be an integer >= 1")
|
|
if name == "suppress_duplicates":
|
|
w = a.get("window_ms")
|
|
if not isinstance(w, int) or w < 1:
|
|
err(f"{where}: window_ms must be an integer >= 1")
|
|
|
|
|
|
def check_case(path, err):
|
|
with open(path) as fh:
|
|
try:
|
|
case = json.load(fh)
|
|
except json.JSONDecodeError as exc:
|
|
return err(f"invalid JSON: {exc}")
|
|
|
|
missing = CASE_KEYS - set(case)
|
|
unknown = set(case) - CASE_KEYS
|
|
if missing:
|
|
err(f"missing top-level keys {sorted(missing)}")
|
|
if unknown:
|
|
err(f"unknown top-level keys {sorted(unknown)}")
|
|
|
|
expected_name = os.path.basename(path)[:-len(".json")]
|
|
if case.get("name") != expected_name:
|
|
err(f"name {case.get('name')!r} does not match filename "
|
|
f"{expected_name!r}")
|
|
if not case.get("description"):
|
|
err("description must not be empty -- it is what the case is for")
|
|
|
|
for i, rule in enumerate(case.get("rules", [])):
|
|
where = f"rules[{i}]"
|
|
if not isinstance(rule, dict) or set(rule) != {"match", "actions"}:
|
|
err(f"{where}: a rule is exactly {{match, actions}}")
|
|
continue
|
|
for j, clause in enumerate(rule["match"]):
|
|
cw = f"{where}.match[{j}]"
|
|
if not isinstance(clause, dict):
|
|
err(f"{cw}: clause must be an object")
|
|
continue
|
|
op = clause.get("op")
|
|
if op in OPS_WITHOUT_VALUE:
|
|
if "value" in clause:
|
|
err(f"{cw}: {op} takes no value")
|
|
allowed = {"field", "op"}
|
|
elif op in OPS_WITH_VALUE:
|
|
if "value" not in clause:
|
|
err(f"{cw}: {op} requires a value")
|
|
allowed = {"field", "op", "value"}
|
|
else:
|
|
err(f"{cw}: unknown op {op!r}")
|
|
continue
|
|
if set(clause) - allowed:
|
|
err(f"{cw}: unknown keys {sorted(set(clause) - allowed)}")
|
|
if not valid_field(clause.get("field", "")):
|
|
err(f"{cw}: {clause.get('field')!r} is not an addressable field")
|
|
if op == "regex":
|
|
try:
|
|
re.compile(clause["value"])
|
|
except re.error as exc:
|
|
err(f"{cw}: not a valid regex: {exc}")
|
|
if not rule["actions"]:
|
|
err(f"{where}: a rule with no actions does nothing")
|
|
for j, action in enumerate(rule["actions"]):
|
|
check_action(action, f"{where}.actions[{j}]", err)
|
|
|
|
for i, rec in enumerate(case.get("inputs", [])):
|
|
check_record(rec, f"inputs[{i}]", err)
|
|
if not case.get("inputs"):
|
|
err("inputs must not be empty -- a case with no input asserts nothing")
|
|
for i, rec in enumerate(case.get("expect", [])):
|
|
check_record(rec, f"expect[{i}]", err)
|
|
|
|
|
|
def main():
|
|
if not os.path.isdir(CASES):
|
|
print(f"no cases directory at {CASES}", file=sys.stderr)
|
|
return 1
|
|
files = sorted(f for f in os.listdir(CASES) if f.endswith(".json"))
|
|
if not files:
|
|
print("no cases found", file=sys.stderr)
|
|
return 1
|
|
|
|
failures = 0
|
|
for name in files:
|
|
errors = []
|
|
check_case(os.path.join(CASES, name), errors.append)
|
|
if errors:
|
|
failures += 1
|
|
print(f"FAIL {name}")
|
|
for e in errors:
|
|
print(f" {e}")
|
|
print(f"\n{len(files) - failures}/{len(files)} cases valid")
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|