Files
OmniRoute/tests/integration/wireCapture.ts
Diego Rodrigues de Sa e Souza a448b146bf cherry-pick(pr-9744): test(integration): add general live-test tool for the real "default" combo + rootless wire capture (#9862)
* test(integration): add general live-test tool for the real "default" combo

Temporary WIP commit on this deferred branch — lands in its own separate
PR once the bug-fix extraction batch is done (never bundled into a
bug-fix PR). Unlike liveGeminiShared.ts (provisions its own narrow
2-model Gemini-only combo), this reads the REAL "default" combo
currently configured on the target instance directly from the DB and
exercises every provider/model step in it directly, bypassing combo
routing, so live-test coverage always matches whatever is actually
configured instead of a hardcoded snapshot.

Live-verified against omniroute-beta (seeded with the real 18-model,
5-provider default combo): 14/18 models pass consistently across
non-streaming + streaming Chat Completions and streaming Responses API.
The 4 consistent failures are real external state (cerebras
credits_exhausted, one deprecated openrouter free-tier model), not code
regressions.

(cherry picked from commit c40b13a48fd897259c56f5122e9e57a3dc7654ba)

* test(integration): add rootless wire-capture correlation to the live-test tool

Temporary WIP commit on this deferred branch — lands in the same final
live-test-tool PR as the general default-combo suite, never bundled into
a bug-fix PR.

liveContainerHarness.ts spins up a dedicated, throwaway podman container
(same runner-base image target as the operator's local dev/beta
containers) so wire-capture tests are fully self-contained: builds the
image if missing, starts the container with a persistent data dir, waits
for health, seeds the real "default" combo + provider connections from
the operator's local omniroute-dev instance (idempotent — only runs once
per data dir), and provisions API keys via the running instance's own
auth flow.

wireCapture.ts captures the container's actual network traffic via
`podman unshare nsenter --net=<container netns> -- tcpdump` — no root
needed, verified working live (this generalizes the root-requiring
`sudo nsenter -t $PID` command scripts/sre/tcp-close-analyzer.py already
documented for the same rootless-Podman netns problem; that script's
docstring now documents both). Capture and analysis needed two real fixes
found only by running the pipeline live: `-U` (unbuffered tcpdump writes)
plus a `pkill -f <pcap path>` fallback, since `podman unshare -> nsenter
-> tcpdump` is a 3-level subprocess chain and SIGTERM to the top-level
process doesn't reach the tcpdump grandchild, leaving an orphaned process
and a truncated/unreadable pcap; and filtering on the container's
internal listening port (20128) rather than the dynamically-assigned host
port, since capture happens inside the container's own network namespace
where only the internal port is meaningful.

live-default-combo-wire-capture.test.ts (gated on RUN_LIVE_WIRE_CAPTURE=1)
ties it together: sends a small representative sample of requests through
the real default combo, then cross-checks each one's app-level JSON
status against the actual HTTP status line observed on the wire via
scripts/sre/tcp-close-analyzer.py's stream reassembly — catching bugs
where the app layer claims success but the wire shows a
truncated/reset stream, not just what liveDefaultComboShared.ts's
existing breadth suite already covers.

Live-verified end-to-end: 4/4 sampled requests correlated correctly
across 8 captured TCP streams, container + capture process fully torn
down afterward (verified no orphaned podman container or tcpdump
process left running).

sendModelRequest/filterActiveModelTargets (liveDefaultComboShared.ts) gain
optional baseUrl/apiKey overrides, defaulting to the existing module-level
omniroute-beta target, so the wire-capture suite can point the same
request-sending logic at its own dedicated container instead.

(cherry picked from commit 914a7e42cbe914f257db9f72eedc902ee1532083)

---------

Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-09 09:53:07 -03:00

155 lines
5.0 KiB
TypeScript

/**
* tests/integration/wireCapture.ts
*
* Rootless wire capture + analysis for live container tests. Uses
* `podman unshare nsenter --net=<container netns>` to run tcpdump without
* sudo/root (verified working against a rootless podman container — see
* scripts/sre/tcp-close-analyzer.py's docstring for the equivalent
* root-requiring `nsenter -t $PID` command this generalizes from), then
* shells out to that same script to reassemble TCP streams and extract
* HTTP request/response lines + correlationId per stream.
*/
import { spawn, spawnSync } from "node:child_process";
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import { fileURLToPath } from "node:url";
const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
const ANALYZER_SCRIPT = `${REPO_ROOT}scripts/sre/tcp-close-analyzer.py`;
export interface WireStreamRecord {
streamKey: string;
client: string | null;
server: string | null;
firstTs: number;
lastTs: number;
durationSec: number;
packetCount: number;
correlationId: string | null;
requestId: string | null;
firstLineFromA: string | null;
firstLineFromB: string | null;
closes: Array<{ ts: number; side: string; src: string; dst: string; flags: string }>;
verdict:
| "client_closed_first"
| "server_closed_first"
| "simultaneous"
| "no_close_seen"
| "unknown_side_closed_first";
}
export interface CaptureHandle {
pcapPath: string;
stop(): Promise<void>;
}
// Best-effort HTTP status line finder — checks both reassembled directions
// since we don't know a priori which one carried the response.
export function responseStatusLine(record: WireStreamRecord): string | null {
for (const line of [record.firstLineFromA, record.firstLineFromB]) {
if (line && /^HTTP\/\d\.\d \d{3}/.test(line)) return line;
}
return null;
}
export function requestLine(record: WireStreamRecord): string | null {
for (const line of [record.firstLineFromA, record.firstLineFromB]) {
if (line && /^(GET|POST|PUT|PATCH|DELETE) /.test(line)) return line;
}
return null;
}
export async function startWireCapture(
netnsPath: string,
pcapPath: string,
bpfFilter: string
): Promise<CaptureHandle> {
if (existsSync(pcapPath)) unlinkSync(pcapPath);
// `-U`: flush each packet to disk as captured instead of buffering, so a
// non-graceful stop still leaves a readable pcap.
const child = spawn(
"podman",
[
"unshare",
"nsenter",
`--net=${netnsPath}`,
"--",
"tcpdump",
"-i",
"any",
"-U",
"-w",
pcapPath,
bpfFilter,
],
{ stdio: ["ignore", "ignore", "pipe"] }
);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("tcpdump did not start listening in time")),
10_000
);
child.stderr?.on("data", (chunk: Buffer) => {
if (chunk.toString().includes("listening on")) {
clearTimeout(timeout);
resolve();
}
});
child.on("exit", (code) => {
clearTimeout(timeout);
reject(new Error(`tcpdump exited early with code ${code}`));
});
});
return {
pcapPath,
async stop() {
// podman unshare -> nsenter -> tcpdump is a 3-level subprocess chain;
// SIGTERM to the top-level `podman` process (the only PID Node's
// child_process handle actually tracks) does not reliably reach the
// tcpdump grandchild, leaving it running as an orphan with a
// never-flushed pcap. pkill by the (unique, per-run) pcap path
// reliably reaches the real tcpdump process regardless of how deep
// the subprocess chain is.
child.kill("SIGTERM");
spawnSync("pkill", ["-f", `tcpdump.*${pcapPath}`]);
await new Promise<void>((resolve) => {
if (child.exitCode !== null) return resolve();
child.on("exit", () => resolve());
setTimeout(resolve, 3_000);
});
// Give the now-dead tcpdump's OS write buffers a moment to land on
// disk before anything tries to read the pcap.
await new Promise((r) => setTimeout(r, 250));
},
};
}
export async function analyzeCapture(pcapPath: string): Promise<WireStreamRecord[]> {
const jsonlPath = pcapPath.replace(/\.pcap$/, "") + ".streams.jsonl";
const result = spawnSync("python3", [ANALYZER_SCRIPT, pcapPath, "--out", jsonlPath], {
encoding: "utf8",
});
if (result.status !== 0) {
throw new Error(`tcp-close-analyzer.py failed: ${result.stderr || result.stdout}`);
}
if (!existsSync(jsonlPath)) return [];
return readFileSync(jsonlPath, "utf8")
.split("\n")
.filter((line) => line.trim())
.map((line) => JSON.parse(line) as WireStreamRecord);
}
export function indexByCorrelationId(records: WireStreamRecord[]): Map<string, WireStreamRecord[]> {
const map = new Map<string, WireStreamRecord[]>();
for (const record of records) {
if (!record.correlationId) continue;
const existing = map.get(record.correlationId) || [];
existing.push(record);
map.set(record.correlationId, existing);
}
return map;
}