mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +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>
261 lines
9.4 KiB
TypeScript
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]);
|
|
},
|
|
};
|
|
}
|