mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
* 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>
155 lines
5.0 KiB
TypeScript
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;
|
|
}
|