mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: all-in-one embedded single-container mode (#377)
Embedded Postgres 17 + Redis via s6-overlay when DATABASE_URL/REDIS_URL are unset; restores the one-command docker run for 2.0. EMBEDDED=0 disables; Compose stays the production path. Verified arm64 (14/14 lifecycle + Compose regression) and amd64 (build + embedded smoke).
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env node
|
||||
// Embedded-mode container lifecycle tests. Requires Docker and a built image.
|
||||
// Usage: SNAPOTTER_IMAGE=snapotter:embed-wip node tests/e2e-docker/embedded-mode.mjs
|
||||
//
|
||||
// Unlike the Playwright e2e-docker specs (which talk to an already-running
|
||||
// container), these drive `docker run`/`stop` directly to exercise the container
|
||||
// lifecycle: bare-run boot, restart persistence, clean shutdown, the non-root and
|
||||
// partial-config fail-fast guards, and the 1.x SQLite auto-detect upgrade.
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
|
||||
const IMAGE = process.env.SNAPOTTER_IMAGE || "snapotter:embed-wip";
|
||||
const NAME = "so-embed-test";
|
||||
const VOL = "so-embed-test-data";
|
||||
const PORT = process.env.SNAPOTTER_TEST_PORT || "13492";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (m) => console.log(` PASS ${m}`);
|
||||
const bad = (m) => {
|
||||
failures++;
|
||||
console.error(` FAIL ${m}`);
|
||||
};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Run docker, returning stdout. Throws on non-zero exit (callers that expect a
|
||||
// non-zero exit wrap in try/catch and read combined output from the error).
|
||||
const docker = (args) =>
|
||||
execFileSync("docker", args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
|
||||
const quiet = (args) => {
|
||||
try {
|
||||
return docker(args);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const combined = (args) => {
|
||||
try {
|
||||
return docker(args);
|
||||
} catch (e) {
|
||||
return `${e.stdout || ""}${e.stderr || ""}`;
|
||||
}
|
||||
};
|
||||
// `docker logs` writes the container's stdout and stderr to docker's own stdout
|
||||
// and stderr respectively. The embedded banner (entrypoint >&2) and Postgres
|
||||
// logs go to stderr, so capture BOTH streams or those assertions miss them.
|
||||
const dockerLogs = (name) => {
|
||||
const res = spawnSync("docker", ["logs", name], { encoding: "utf-8" });
|
||||
return `${res.stdout || ""}${res.stderr || ""}`;
|
||||
};
|
||||
const cleanup = () => {
|
||||
quiet(["rm", "-f", NAME]);
|
||||
quiet(["volume", "rm", VOL]);
|
||||
};
|
||||
|
||||
async function waitHealthy(timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${PORT}/api/v1/health`);
|
||||
if (res.ok) {
|
||||
const body = await res.json();
|
||||
if (body.status === "healthy") return true;
|
||||
}
|
||||
} catch {
|
||||
// not accepting connections yet
|
||||
}
|
||||
await sleep(3000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const countMatches = (haystack, re) => (haystack.match(re) || []).length;
|
||||
|
||||
async function main() {
|
||||
console.log(`Embedded-mode tests against ${IMAGE}`);
|
||||
cleanup();
|
||||
|
||||
// 1. Bare `docker run` with no DB env boots and becomes healthy.
|
||||
console.log("\n[1] bare docker run boots healthy");
|
||||
docker(["run", "-d", "--name", NAME, "-p", `${PORT}:1349`, "-v", `${VOL}:/data`, IMAGE]);
|
||||
const healthy = await waitHealthy(300000);
|
||||
healthy
|
||||
? ok("embedded container reached healthy")
|
||||
: bad("embedded container never became healthy");
|
||||
|
||||
const logs1 = dockerLogs(NAME);
|
||||
logs1.includes("embedded mode") ? ok("embedded banner printed") : bad("no embedded banner");
|
||||
countMatches(logs1, /first-boot initdb/g) === 1
|
||||
? ok("initdb ran exactly once")
|
||||
: bad("initdb did not run exactly once");
|
||||
|
||||
// 2. Restart reuses PGDATA (no second initdb), stays healthy.
|
||||
console.log("\n[2] restart persists data, no re-init");
|
||||
docker(["restart", NAME]);
|
||||
(await waitHealthy(180000)) ? ok("healthy after restart") : bad("unhealthy after restart");
|
||||
countMatches(dockerLogs(NAME), /first-boot initdb/g) === 1
|
||||
? ok("no second initdb on restart")
|
||||
: bad("re-initialized on restart (data loss risk)");
|
||||
|
||||
// 3. Clean shutdown: docker stop returns fast, Postgres logs a clean stop.
|
||||
console.log("\n[3] clean shutdown");
|
||||
const t0 = Date.now();
|
||||
docker(["stop", NAME]);
|
||||
const stopMs = Date.now() - t0;
|
||||
stopMs < 9000
|
||||
? ok(`stopped in ${stopMs}ms (before kill timeout)`)
|
||||
: bad(`stop took ${stopMs}ms (likely SIGKILL)`);
|
||||
dockerLogs(NAME).includes("database system is shut down")
|
||||
? ok("Postgres logged a clean shutdown")
|
||||
: bad("no clean Postgres shutdown line");
|
||||
cleanup();
|
||||
|
||||
// 4. Non-root fails fast.
|
||||
console.log("\n[4] non-root fail-fast");
|
||||
combined(["run", "--rm", "--user", "1000:1000", IMAGE]).includes("embedded mode needs root")
|
||||
? ok("non-root rejected with guidance")
|
||||
: bad("non-root not rejected");
|
||||
|
||||
// 5. Partial config fails fast.
|
||||
console.log("\n[5] partial-config fail-fast");
|
||||
combined(["run", "--rm", "-e", "REDIS_URL=redis://x:6379", IMAGE]).includes(
|
||||
"set BOTH DATABASE_URL and REDIS_URL",
|
||||
)
|
||||
? ok("partial config rejected")
|
||||
: bad("partial config not rejected");
|
||||
|
||||
// 6. 1.x SQLite auto-detect upgrade: a /data/snapotter.db present with no
|
||||
// SQLITE_MIGRATE_PATH set is auto-imported on first boot. Seed all 10 tables
|
||||
// the importer reads (empty) so the import succeeds without column-mismatch;
|
||||
// this proves the auto-detect wiring (row migration is covered elsewhere).
|
||||
console.log("\n[6] 1.x SQLite auto-detect upgrade");
|
||||
cleanup();
|
||||
const tables = [
|
||||
"users",
|
||||
"teams",
|
||||
"settings",
|
||||
"roles",
|
||||
"sessions",
|
||||
"api_keys",
|
||||
"pipelines",
|
||||
"jobs",
|
||||
"audit_log",
|
||||
"user_files",
|
||||
];
|
||||
const seedSql = tables.map((t) => `CREATE TABLE ${t}(id TEXT);`).join(" ");
|
||||
docker([
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
`${VOL}:/data`,
|
||||
"alpine:3.20",
|
||||
"sh",
|
||||
"-c",
|
||||
`apk add --no-cache sqlite >/dev/null 2>&1 && sqlite3 /data/snapotter.db "${seedSql}"`,
|
||||
]);
|
||||
docker(["run", "-d", "--name", NAME, "-p", `${PORT}:1349`, "-v", `${VOL}:/data`, IMAGE]);
|
||||
(await waitHealthy(300000))
|
||||
? ok("healthy after upgrade boot")
|
||||
: bad("unhealthy after upgrade boot");
|
||||
dockerLogs(NAME).includes("Imported 1.x SQLite database")
|
||||
? ok("auto-detected and imported the 1.x SQLite DB")
|
||||
: bad("did not auto-import the 1.x SQLite DB");
|
||||
|
||||
// Second boot must NOT re-import (target Postgres now non-empty).
|
||||
docker(["restart", NAME]);
|
||||
(await waitHealthy(180000))
|
||||
? ok("healthy after second boot")
|
||||
: bad("unhealthy after second boot");
|
||||
countMatches(dockerLogs(NAME), /Imported 1\.x SQLite database/g) === 1
|
||||
? ok("did not re-import on the second boot")
|
||||
: bad("re-imported on the second boot");
|
||||
cleanup();
|
||||
|
||||
console.log(`\n${failures === 0 ? "ALL PASSED" : `${failures} FAILED`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user