PIPELINE §5 step 5: collector admission emit tests (§4.4)
Adds the missing emit-tests so every collector in KNOWN_COLLECTORS
has end-to-end coverage:
* test_proc_emits_rows_against_self_pid
Samples /proc/<own pid> for ~0.6s. Asserts ≥3 rows + populated
core fields (cpu_user_jiffies, rss_bytes, vsize_bytes). Works
anywhere with /proc.
* test_pcap_bucketize_emits_rows_from_synthetic_capture
Builds a 2-packet Ethernet+IPv4+TCP pcap in-memory, feeds it
to pcap.bucketize, asserts ≥1 row written + total packet count
across buckets matches input. Covers BOTH the pcap and netflow
collectors (netflow IS the bucketized pcap output).
* test_every_known_collector_has_emit_coverage
Cross-cutting tripwire: for every name in KNOWN_COLLECTORS,
either there's a test_collectors_emit.py test or there's an
explicit COLLECTOR_TEST_CARVE_OUTS entry. Adding a collector
to KNOWN_COLLECTORS without an emit test fails this. Carve-outs
today: qmp (covered by tests/test_qmp.py — needs running QEMU
for real-binary emit) and guest_agent (covered by
tests/test_guest_agent.py — needs a real VM with the agent
baked in).
The carve-outs are explicit, not implicit. A drift where someone
adds a new collector without a real-binary emit test fails CI before
the manifest can include it.
272 tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
22269e175d
commit
0d51b9b253
1 changed files with 169 additions and 0 deletions
|
|
@ -143,6 +143,175 @@ def test_perf_emits_rows_against_busy_pid(tmp_path: Path) -> None:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# proc — must emit ≥1 row when run against a live PID
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_proc_emits_rows_against_self_pid(tmp_path: Path) -> None:
|
||||
"""The /proc collector samples a live PID. Anyone running this test
|
||||
has a /proc/<pid>/stat for their own process, so this works in any
|
||||
environment that has /proc — Linux, including CI containers."""
|
||||
from collectors import proc_qemu
|
||||
out = tmp_path / "telemetry-proc.jsonl"
|
||||
rows = _run_collector_briefly(
|
||||
proc_qemu.run_loop,
|
||||
seconds=0.6,
|
||||
pid=os.getpid(),
|
||||
output_path=out,
|
||||
t_mono_origin_ns=0,
|
||||
interval_ms=100,
|
||||
)
|
||||
assert rows >= 3, (
|
||||
f"proc collector wrote {rows} rows for ~5 expected ticks "
|
||||
f"in 600ms. PIPELINE.md §4.4."
|
||||
)
|
||||
parsed = [json.loads(l) for l in out.read_text().splitlines()]
|
||||
assert all(r["source"] == "host_proc" for r in parsed)
|
||||
# Every row must have non-None values for the core fields — those
|
||||
# are direct /proc reads that always succeed for a live PID.
|
||||
for r in parsed:
|
||||
assert r["cpu_user_jiffies"] is not None
|
||||
assert r["rss_bytes"] is not None
|
||||
assert r["vsize_bytes"] is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pcap + netflow — bucketize a synthesized minimal pcap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_minimal_pcap(path: Path) -> None:
|
||||
"""Write a tiny pcap with two TCP packets so bucketize() has
|
||||
something to count. Avoids needing tcpdump in CI."""
|
||||
import struct
|
||||
# pcap global header — microsec resolution, link-type 1 (Ethernet).
|
||||
hdr = struct.pack("<IHHiIII",
|
||||
0xa1b2c3d4, # magic
|
||||
2, 4, # version major.minor
|
||||
0, 0, # thiszone, sigfigs
|
||||
65535, # snaplen
|
||||
1) # linktype: ethernet
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("wb") as f:
|
||||
f.write(hdr)
|
||||
# Two synthetic frames: Ethernet + IPv4 + TCP. Keep them short
|
||||
# but well-formed enough that the collector's parser walks them.
|
||||
for ts_us in (1_000_000, 1_050_000):
|
||||
# 14B Ethernet (src + dst MAC + ethertype 0x0800)
|
||||
eth = (b"\x00\x11\x22\x33\x44\x55"
|
||||
b"\x66\x77\x88\x99\xaa\xbb"
|
||||
b"\x08\x00")
|
||||
# 20B IPv4 — TCP (proto 6) from 10.200.0.1 → 10.200.0.10.
|
||||
ipv4 = struct.pack(
|
||||
">BBHHHBBHII",
|
||||
0x45, 0, 40, # ver/ihl, tos, total len
|
||||
0x1234, 0, # id, flags+frag
|
||||
64, 6, 0, # ttl, proto=tcp, checksum (skip)
|
||||
int.from_bytes(b"\x0a\xc8\x00\x01", "big"), # 10.200.0.1
|
||||
int.from_bytes(b"\x0a\xc8\x00\x0a", "big"), # 10.200.0.10
|
||||
)
|
||||
# 20B TCP — sport 1234, dport 80, ACK flag
|
||||
tcp = struct.pack(
|
||||
">HHIIBBHHH",
|
||||
1234, 80,
|
||||
0, 0, 0x50, 0x10, 65535, 0, 0,
|
||||
)
|
||||
payload = eth + ipv4 + tcp
|
||||
rec_hdr = struct.pack(
|
||||
"<IIII",
|
||||
ts_us // 1_000_000,
|
||||
ts_us % 1_000_000,
|
||||
len(payload), len(payload),
|
||||
)
|
||||
f.write(rec_hdr + payload)
|
||||
|
||||
|
||||
def test_pcap_bucketize_emits_rows_from_synthetic_capture(tmp_path: Path) -> None:
|
||||
"""bucketize is the netflow collector. With a synthesized
|
||||
Ethernet+IPv4+TCP pcap, it MUST emit at least one bucket row.
|
||||
|
||||
Refs: PIPELINE.md §4.4 — netflow must produce ≥1 row against a
|
||||
realistic packet capture. Synthesizing the pcap keeps the test
|
||||
hermetic (no tcpdump or live interface needed in CI)."""
|
||||
from collectors import pcap as pcap_collector
|
||||
pcap_path = tmp_path / "network.pcap"
|
||||
_build_minimal_pcap(pcap_path)
|
||||
netflow_path = tmp_path / "netflow.jsonl"
|
||||
rows = pcap_collector.bucketize(
|
||||
pcap_path, netflow_path,
|
||||
bucket_ms=100,
|
||||
t_mono_origin_ns=0,
|
||||
bridge_ip="10.200.0.1",
|
||||
)
|
||||
assert rows >= 1, (
|
||||
f"netflow bucketize wrote {rows} rows for a 2-packet pcap. "
|
||||
f"PIPELINE.md §4.4."
|
||||
)
|
||||
parsed = [json.loads(l) for l in netflow_path.read_text().splitlines()]
|
||||
assert parsed
|
||||
# The aggregator must have counted both packets — over the 50ms
|
||||
# span of our synthetic capture, packet count should be > 0.
|
||||
total_pkts = sum(r.get("pkts_in", 0) + r.get("pkts_out", 0)
|
||||
for r in parsed)
|
||||
assert total_pkts == 2, f"expected 2 packets across all buckets, got {total_pkts}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-cutting: every name in KNOWN_COLLECTORS must be exercised by
|
||||
# either an emit-test here or a documented carve-out below.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Carve-outs: collectors that need a running guest VM to exercise
|
||||
# end-to-end. Each entry MUST cite the unit-level test that exercises
|
||||
# its parser/protocol code and the specific reason a real-binary emit
|
||||
# test isn't tractable in CI.
|
||||
COLLECTOR_TEST_CARVE_OUTS = {
|
||||
"qmp": (
|
||||
"tests/test_qmp.py — exercises the QMP wire parser against "
|
||||
"captured query-status / query-blockstats fixtures. Real-binary "
|
||||
"emit needs a running QEMU; covered in production by the "
|
||||
"Tier-3 emit-test on lab hosts."
|
||||
),
|
||||
"guest_agent": (
|
||||
"tests/test_guest_agent.py — exercises run_loop with a fake "
|
||||
"agent unix-socket server feeding JSONL. Real-VM emit needs "
|
||||
"a guest with the agent baked in (cidata path, covered by "
|
||||
"production data on lab hosts)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_every_known_collector_has_emit_coverage() -> None:
|
||||
"""For each collector name in KNOWN_COLLECTORS, either there's a
|
||||
test_collectors_emit.py emit test against a real binary, or there's
|
||||
an explicit carve-out in COLLECTOR_TEST_CARVE_OUTS naming the
|
||||
parser-level test that covers it. Adding a new collector without
|
||||
either fails this assertion."""
|
||||
from orchestrator.manifest import KNOWN_COLLECTORS
|
||||
test_src = Path(__file__).read_text()
|
||||
expected_emit_test_names = {
|
||||
"proc": "test_proc_emits_rows_against",
|
||||
"perf": "test_perf_emits_rows_against",
|
||||
"pcap": "test_pcap_bucketize_emits_rows",
|
||||
"netflow": "test_pcap_bucketize_emits_rows",
|
||||
}
|
||||
for collector in KNOWN_COLLECTORS:
|
||||
if collector in COLLECTOR_TEST_CARVE_OUTS:
|
||||
continue
|
||||
needle = expected_emit_test_names.get(collector)
|
||||
assert needle is not None, (
|
||||
f"collector {collector!r} in KNOWN_COLLECTORS has no entry in "
|
||||
f"expected_emit_test_names and no COLLECTOR_TEST_CARVE_OUTS "
|
||||
f"entry. Either add a test or document the carve-out."
|
||||
)
|
||||
assert needle in test_src, (
|
||||
f"collector {collector!r} expected an emit-test name "
|
||||
f"containing {needle!r} in this file. PIPELINE.md §4.4."
|
||||
)
|
||||
|
||||
|
||||
def test_run_tier3_demo_wires_collector_sockets_into_episode_config() -> None:
|
||||
"""`run_tier3_demo.py` must pass qmp_socket / guest_agent_socket /
|
||||
bridge_iface to EpisodeConfig the same way `run_real_vm_demo.py`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue