Files
OmniRoute/tests/integration/liveContainerHarness.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

261 lines
9.4 KiB
TypeScript

/**
* tests/integration/liveContainerHarness.ts
*
* Spins up a dedicated, throwaway podman container running this checkout's
* own code (runner-base target, same as the operator's local dev/beta
* containers) so wire-capture live tests are fully self-contained — no
* dependency on a manually-managed systemd quadlet.
*
* The container's DATA_DIR is a persistent host directory (not wiped between
* runs) so the "default" combo + real provider connections only need
* seeding once; seeding is idempotent and copies from the operator's local
* omniroute-dev instance (same source used for the manual omniroute-beta
* seed earlier this session).
*/
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import Database from "better-sqlite3";
const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
export const LIVE_CONTAINER_ENABLED = process.env.RUN_LIVE_WIRE_CAPTURE === "1";
const IMAGE_TAG = process.env.LIVE_CONTAINER_IMAGE || "localhost/omniroute:live-wire-test";
const CONTAINER_NAME = process.env.LIVE_CONTAINER_NAME || "omniroute-live-wire-test";
const DATA_DIR_HOST =
process.env.LIVE_CONTAINER_DATA_DIR || "/data/podman-data/omniroute-live-wire-test/data";
const ENV_FILE = process.env.LIVE_CONTAINER_ENV_FILE || "/data/podman-data/omniroute/omniroute.env";
// Source DB to seed the "default" combo + provider connections from — the
// operator's local omniroute-dev instance, same source used for the manual
// omniroute-beta seed earlier this session.
const SEED_SOURCE_DB =
process.env.LIVE_CONTAINER_SEED_SOURCE_DB ||
"/home/markus/code/podman/OmniRoute/data/storage.sqlite";
const SEED_PROVIDERS = ["gemini", "openrouter", "mistral", "cerebras"];
export interface LiveContainerHandle {
baseUrl: string;
apiKey: string;
managementApiKey: string;
containerName: string;
netnsPath: string;
hostPort: number;
dataDir: string;
stop(): Promise<void>;
}
function run(cmd: string, args: string[], opts: { input?: string } = {}): string {
const result = spawnSync(cmd, args, {
cwd: REPO_ROOT,
encoding: "utf8",
input: opts.input,
maxBuffer: 64 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(
`${cmd} ${args.join(" ")} failed (exit ${result.status}):\n${result.stderr || result.stdout}`
);
}
return result.stdout.trim();
}
function tryRun(cmd: string, args: string[]): string | null {
const result = spawnSync(cmd, args, { cwd: REPO_ROOT, encoding: "utf8" });
return result.status === 0 ? result.stdout.trim() : null;
}
function ensureImageBuilt(): void {
const existing = tryRun("podman", ["images", "-q", IMAGE_TAG]);
if (existing) {
console.log(` [container] image ${IMAGE_TAG} already exists (${existing}) — reusing`);
return;
}
console.log(` [container] building ${IMAGE_TAG} (runner-base target — this takes a while)...`);
run("podman", ["build", "--target", "runner-base", "-t", IMAGE_TAG, "."]);
}
function stopExistingContainer(): void {
tryRun("podman", ["rm", "-f", CONTAINER_NAME]);
}
function startContainer(): { hostPort: number; netnsPath: string } {
if (!existsSync(DATA_DIR_HOST)) {
mkdirSync(DATA_DIR_HOST, { recursive: true });
}
// podman unshare owns the rootless user namespace these directories'
// native uid mappings live in — plain chmod as the host user fails with
// EPERM on files podman previously wrote as a different mapped uid.
tryRun("podman", ["unshare", "chmod", "-R", "a+rwX", DATA_DIR_HOST]);
run("podman", [
"run",
"-d",
"--name",
CONTAINER_NAME,
"-p",
"127.0.0.1::20128",
"-v",
`${DATA_DIR_HOST}:/app/data`,
"--env-file",
ENV_FILE,
IMAGE_TAG,
]);
const portOutput = run("podman", ["port", CONTAINER_NAME, "20128/tcp"]);
const hostPort = Number(portOutput.split(":").pop());
if (!Number.isFinite(hostPort)) {
throw new Error(`could not parse assigned host port from: ${portOutput}`);
}
const netnsPath = run("podman", [
"inspect",
CONTAINER_NAME,
"--format",
"{{.NetworkSettings.SandboxKey}}",
]);
return { hostPort, netnsPath };
}
async function waitForHealth(baseUrl: string, timeoutMs = 60_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const res = await fetch(`${baseUrl}/api/monitoring/health`);
if (res.ok) return;
} catch (err) {
lastError = err;
}
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error(`container never became healthy within ${timeoutMs}ms: ${lastError}`);
}
// Idempotent: only copies rows if the target has no "default" combo yet.
// Direct SQLite access (not the src/lib/db/ CRUD functions) is deliberate
// here, same as liveGeminiShared.ts's ensureGeminiProvider() — cloning an
// existing row's already-encrypted apiKey blob byte-for-byte has no CRUD
// equivalent, and both instances share the same API_KEY_SECRET (same
// --env-file), so the encrypted value decrypts correctly on the target too.
async function seedDefaultComboAndConnections(): Promise<void> {
const targetPath = `${DATA_DIR_HOST}/storage.sqlite`;
if (!existsSync(targetPath)) {
console.log(` [container] target DB not created yet, skipping seed this pass`);
return;
}
if (!existsSync(SEED_SOURCE_DB)) {
console.warn(` [container] seed source DB not found at ${SEED_SOURCE_DB} — skipping seed`);
return;
}
const target = new Database(targetPath);
const existingCombo = target.prepare("SELECT 1 FROM combos WHERE name = 'default'").get();
if (existingCombo) {
console.log(` [container] "default" combo already seeded — skipping`);
target.close();
return;
}
const source = new Database(SEED_SOURCE_DB, { readonly: true });
const connCols = source.prepare("PRAGMA table_info(provider_connections)").all() as Array<{
name: string;
}>;
const colList = connCols.map((c) => `"${c.name}"`).join(",");
const placeholders = connCols.map((c) => `@${c.name}`).join(",");
const insertConn = target.prepare(
`INSERT OR REPLACE INTO provider_connections (${colList}) VALUES (${placeholders})`
);
let copied = 0;
for (const provider of SEED_PROVIDERS) {
const rows = source
.prepare("SELECT * FROM provider_connections WHERE provider = ? AND is_active = 1")
.all(provider);
for (const row of rows) {
insertConn.run(row);
copied++;
}
}
const comboRow = source.prepare("SELECT * FROM combos WHERE name = 'default'").get() as
Record<string, unknown> | undefined;
if (comboRow) {
const comboCols = Object.keys(comboRow);
const comboColList = comboCols.map((c) => `"${c}"`).join(",");
const comboPlaceholders = comboCols.map((c) => `@${c}`).join(",");
target
.prepare(`INSERT OR REPLACE INTO combos (${comboColList}) VALUES (${comboPlaceholders})`)
.run(comboRow);
}
console.log(` [container] seeded "default" combo + ${copied} provider connection(s)`);
source.close();
target.close();
}
async function provisionApiKeys(
baseUrl: string
): Promise<{ apiKey: string; managementApiKey: string }> {
const passwordLine = spawnSync("grep", ["INITIAL_PASSWORD", ENV_FILE], {
encoding: "utf8",
}).stdout.trim();
const password = passwordLine.split("=").slice(1).join("=") || "CHANGEME";
const login = await fetch(`${baseUrl}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const cookie = login.headers.get("set-cookie");
if (!cookie) throw new Error("login did not return a session cookie");
async function createKey(name: string, scopes?: string[]): Promise<string> {
const res = await fetch(`${baseUrl}/api/keys`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookie! },
body: JSON.stringify({ name, ...(scopes ? { scopes } : {}) }),
});
if (!res.ok) throw new Error(`failed to create API key "${name}": ${res.status}`);
const data = (await res.json()) as { key: string };
return data.key;
}
const apiKey = await createKey("live-wire-capture-test");
const managementApiKey = await createKey("live-wire-capture-test-mgmt", ["manage"]);
return { apiKey, managementApiKey };
}
export async function startLiveContainer(): Promise<LiveContainerHandle> {
stopExistingContainer();
ensureImageBuilt();
const { hostPort, netnsPath } = startContainer();
const baseUrl = `http://127.0.0.1:${hostPort}`;
await waitForHealth(baseUrl);
// The container creates storage.sqlite etc. on first boot under its own
// internal uid mapping — chmod again now that those files exist, since
// the earlier pre-start chmod only reached the (then-empty) directory.
// Without this, seedDefaultComboAndConnections()'s direct host-side
// better-sqlite3 open fails with "attempt to write a readonly database"
// (same root cause hit manually with omniroute-beta earlier this session).
tryRun("podman", ["unshare", "chmod", "-R", "a+rwX", DATA_DIR_HOST]);
await seedDefaultComboAndConnections();
const { apiKey, managementApiKey } = await provisionApiKeys(baseUrl);
return {
baseUrl,
apiKey,
managementApiKey,
containerName: CONTAINER_NAME,
netnsPath,
hostPort,
dataDir: DATA_DIR_HOST,
async stop() {
tryRun("podman", ["stop", "-t", "5", CONTAINER_NAME]);
tryRun("podman", ["rm", "-f", CONTAINER_NAME]);
},
};
}