diff --git a/packages/stack/tests/createStack-docker.e2e.test.ts b/packages/stack/tests/createStack-docker.e2e.test.ts index 08d2d49971..bb6b191463 100644 --- a/packages/stack/tests/createStack-docker.e2e.test.ts +++ b/packages/stack/tests/createStack-docker.e2e.test.ts @@ -1,22 +1,78 @@ // oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-fetch, effecttsgo/node-builtin-import -- Docker e2e tests drive the native CLI, Docker, and HTTP boundaries from Vitest's Promise callbacks. import { createClient, type SupabaseClient } from "@supabase/supabase-js"; -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { activationTimeoutSecondsForService } from "../src/ServiceActivation.ts"; +import { dockerContainerName } from "../src/StackIdentity.ts"; import { createStack, type StackHandle } from "../src/node.ts"; import { dependencyTimeoutSecondsForServices } from "../src/services/health-budgets.ts"; -import { DEFAULT_VERSIONS } from "../src/versions.ts"; +import { + DEFAULT_VERSIONS, + SERVICE_NAMES, + dockerImageForService, + type ServiceName, +} from "../src/versions.ts"; import { setupTestTable } from "./helpers/e2e.ts"; -const STACK_DOCKER_E2E_TEST_TIMEOUT_MS = 180_000; -const STACK_DOCKER_E2E_SETUP_OVERHEAD_MS = 90_000; -const STACK_DOCKER_E2E_SETUP_TIMEOUT_MS = +const STACK_DOCKER_E2E_SETUP_OVERHEAD_MS = 180_000; +const STACK_DOCKER_E2E_FULL_START_TIMEOUT_MS = dependencyTimeoutSecondsForServices(["postgres"]) * 1000 + STACK_DOCKER_E2E_SETUP_OVERHEAD_MS; -const ANALYTICS_COLD_START_TEST_TIMEOUT_MS = activationTimeoutSecondsForService("analytics") * 1000; +const STACK_DOCKER_E2E_JOURNEY_OVERHEAD_MS = 240_000; +const STACK_DOCKER_E2E_TEST_TIMEOUT_MS = + 2 * STACK_DOCKER_E2E_FULL_START_TIMEOUT_MS + STACK_DOCKER_E2E_JOURNEY_OVERHEAD_MS; +const STACK_DOCKER_E2E_AFTER_ALL_SHUTDOWN_TIMEOUT_MS = 60_000; +const STACK_DOCKER_E2E_AFTER_ALL_INSPECTION_OVERHEAD_MS = 30_000; +const STACK_DOCKER_E2E_AFTER_ALL_TIMEOUT_MS = + STACK_DOCKER_E2E_AFTER_ALL_SHUTDOWN_TIMEOUT_MS + + STACK_DOCKER_E2E_AFTER_ALL_INSPECTION_OVERHEAD_MS; + +const EAGER_SERVICES: ReadonlyArray = [ + "postgres", + "realtime", + "mailpit", + "pgmeta", + "studio", + "analytics", + "vector", + "pooler", +]; + +const LAZY_SERVICES: ReadonlyArray = [ + "postgrest", + "auth", + "edge-runtime", + "storage", + "imgproxy", +]; + +const ownedDockerContainers = ( + identity: string, +): ReadonlyArray<{ readonly name: string; readonly image: string }> => + SERVICE_NAMES.flatMap((service) => { + const name = dockerContainerName(service, identity); + const output = execFileSync( + "docker", + ["ps", "-a", "--filter", `name=^${name}$`, "--format", "{{.Names}}\t{{.Image}}"], + { stdio: ["ignore", "pipe", "pipe"] }, + ) + .toString() + .trim(); + if (output === "") return []; + return output.split(/\r?\n/).map((line) => { + const [containerName, image] = line.split("\t"); + return { name: containerName ?? name, image: image ?? "" }; + }); + }); + +const forceRemoveOwnedDockerContainers = (containerNames: ReadonlyArray): void => { + if (containerNames.length === 0) return; + try { + execFileSync("docker", ["rm", "-f", ...containerNames], { stdio: "ignore" }); + } catch {} +}; function hasDockerDaemon(): boolean { try { @@ -41,8 +97,34 @@ dockerDescribe("createStack e2e (docker mode)", () => { stack = await createStack({ mode: "docker", jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + servicePolicies: { + postgres: "eager", + postgrest: "lazy", + auth: "lazy", + "edge-runtime": "lazy", + realtime: "eager", + storage: "lazy", + imgproxy: "lazy", + mailpit: "eager", + pgmeta: "eager", + studio: "eager", + analytics: "eager", + vector: "eager", + pooler: "eager", + }, postgres: { dataDir }, + postgrest: {}, + auth: {}, + edgeRuntime: {}, + realtime: {}, + storage: {}, + imgproxy: {}, + mailpit: {}, + pgmeta: {}, + studio: {}, analytics: {}, + vector: {}, + pooler: {}, }); try { @@ -65,192 +147,364 @@ dockerDescribe("createStack e2e (docker mode)", () => { apiPort = new URL(stack.url).port; supabase = createClient(stack.url, stack.publishableKey); - }, STACK_DOCKER_E2E_SETUP_TIMEOUT_MS); + }, STACK_DOCKER_E2E_FULL_START_TIMEOUT_MS); afterAll(async () => { - await stack?.dispose(); + const teardownFailures: unknown[] = []; - // Verify all Docker containers are cleaned up after dispose - const remaining = execSync(`docker ps -q --filter name=supabase-.*-${apiPort}`) - .toString() - .trim(); - expect(remaining).toBe(""); + try { + await stack?.dispose(); + } catch (error) { + teardownFailures.push(error); + } try { - rmSync(dataDir, { recursive: true, force: true }); - } catch {} - }, 30_000); + // Verify all exact owned Docker containers are cleaned up, including stopped containers. + if (apiPort !== undefined) { + const remaining = ownedDockerContainers(apiPort).map((container) => container.name); + if (remaining.length > 0) { + forceRemoveOwnedDockerContainers(remaining); + teardownFailures.push( + new Error(`Owned Docker containers remained after dispose: ${remaining.join(", ")}`), + ); + } + } + } catch (error) { + teardownFailures.push(error); + } - test( - "runs the core services in Docker containers and serves health endpoints", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - await Promise.all([stack.startService("postgrest"), stack.startService("auth")]); + try { + rmSync(dataDir, { recursive: true, force: true }); + } catch (error) { + teardownFailures.push(error); + } - const runningImages = execSync("docker ps --format '{{.Image}}'").toString(); - expect(runningImages).toContain( - `ghcr.io/supabase/cli/postgrest:${DEFAULT_VERSIONS.postgrest}`, - ); - expect(runningImages).toContain(`ghcr.io/supabase/cli/postgres:${DEFAULT_VERSIONS.postgres}`); - expect(runningImages).toContain(`ghcr.io/supabase/cli/auth:${DEFAULT_VERSIONS.auth}`); - - const [proxyRes, authRes] = await Promise.all([ - fetch(`${stack.url}/health`), - fetch(`${stack.url}/auth/v1/health`), - ]); - expect(proxyRes.status).toBe(200); - expect(await proxyRes.text()).toBe("OK"); - expect(authRes.status).toBe(200); - expect(await authRes.json()).toEqual( - expect.objectContaining({ description: expect.any(String) }), - ); - }, - ); + if (teardownFailures.length === 1) { + throw teardownFailures[0]; + } + if (teardownFailures.length > 1) { + throw new AggregateError(teardownFailures, "Docker e2e teardown failed"); + } + }, STACK_DOCKER_E2E_AFTER_ALL_TIMEOUT_MS); test( - "runs the edge runtime in Docker and serves the functions placeholder through the local gateway", + "qualifies the complete slim Docker graph through one public user journey", { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, async () => { - const functionsRes = await fetch(`${stack.url}/functions/v1/test`); - await stack.serviceReady("edge-runtime"); - const runningImages = execSync("docker ps --format '{{.Image}}'").toString(); - const states = await stack.getStatus(); - - expect(runningImages).toContain( - `ghcr.io/supabase/cli/edge-runtime:${DEFAULT_VERSIONS["edge-runtime"]}`, - ); - expect(states).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: "edge-runtime", status: "Healthy" }), - ]), - ); - expect(functionsRes.status).toBe(501); - expect(await functionsRes.json()).toEqual({ - code: "FUNCTIONS_NOT_CONFIGURED", - message: "Edge Functions are not configured for this local stack yet.", - }); - }, - ); - - test( - "cold-starts analytics through lazy service activation", - { timeout: ANALYTICS_COLD_START_TEST_TIMEOUT_MS }, - async () => { - expect(await stack.getServiceStatus("analytics")).toEqual( - expect.objectContaining({ status: "Dormant" }), - ); - - await stack.startService("analytics"); - - const [runningImages, states] = await Promise.all([ - Promise.resolve(execSync("docker ps --format '{{.Image}}'").toString()), - stack.getStatus(), - ]); - - expect(runningImages).toContain( - `ghcr.io/supabase/cli/analytics:${DEFAULT_VERSIONS.analytics}`, - ); - expect(states).toEqual( - expect.arrayContaining([expect.objectContaining({ name: "analytics", status: "Healthy" })]), - ); - }, - ); + try { + const initialStates = await stack.getStatus(); + expect(initialStates.map((state) => state.name).toSorted()).toEqual( + [...SERVICE_NAMES].toSorted(), + ); - test( - "supports the docker auth signup and session golden path", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - const testEmail = `test-${Date.now()}@example.com`; - const testPassword = "test-password-123"; - - const signUp = await supabase.auth.signUp({ - email: testEmail, - password: testPassword, - }); - expect(signUp.error).toBeNull(); - expect(signUp.data.user?.email).toBe(testEmail); - expect(signUp.data.session).toBeDefined(); - - const signIn = await supabase.auth.signInWithPassword({ - email: testEmail, - password: testPassword, - }); - expect(signIn.error).toBeNull(); - expect(signIn.data.user?.email).toBe(testEmail); - expect(signIn.data.session?.access_token).toBeTruthy(); - - const currentUser = await supabase.auth.getUser(); - expect(currentUser.error).toBeNull(); - expect(currentUser.data.user?.email).toBe(testEmail); - }, - ); + const [storageRes, pgmetaRes, analyticsRes] = await Promise.all([ + fetch(`${stack.url}/storage/v1/status`), + fetch(`${stack.url}/pg/health`), + fetch(`${stack.url}/analytics/v1/health`), + ]); + expect(storageRes.status, "storage status").toBe(200); + expect(pgmetaRes.status, "pgmeta status").toBe(200); + expect(analyticsRes.status, "analytics status").toBe(200); + + const functionsRes = await fetch(`${stack.url}/functions/v1/test`); + expect(functionsRes.status).toBe(501); + expect(await functionsRes.json()).toEqual({ + code: "FUNCTIONS_NOT_CONFIGURED", + message: "Edge Functions are not configured for this local stack yet.", + }); - test( - "supports a full docker PostgREST CRUD golden path", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - const seeded = await supabase.from("todos").select("*").order("id"); - expect(seeded.error).toBeNull(); - expect(seeded.data).toHaveLength(2); - - const inserted = await supabase - .from("todos") - .insert({ title: "E2E test todo" }) - .select() - .single(); - expect(inserted.error).toBeNull(); - expect(inserted.data?.title).toBe("E2E test todo"); - - const updated = await supabase - .from("todos") - .update({ completed: true }) - .eq("title", "E2E test todo") - .select() - .single(); - expect(updated.error).toBeNull(); - expect(updated.data?.completed).toBe(true); - - const deleted = await supabase.from("todos").delete().eq("title", "E2E test todo"); - expect(deleted.error).toBeNull(); - - const remaining = await supabase.from("todos").select("*").eq("title", "E2E test todo"); - expect(remaining.data).toHaveLength(0); - }, - ); + const testEmail = `test-${Date.now()}@example.com`; + const testPassword = "test-password-123"; + const signUp = await supabase.auth.signUp({ + email: testEmail, + password: testPassword, + }); + expect(signUp.error).toBeNull(); + expect(signUp.data.user?.email).toBe(testEmail); + expect(signUp.data.session).toBeDefined(); - test( - "restarts the Studio graph with its Pgmeta dependency", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - const graphDataDir = mkdtempSync(join(tmpdir(), "supabase-e2e-docker-graph-")); - let graphStack: StackHandle | undefined; - try { - graphStack = await createStack({ - mode: "docker", - postgres: { dataDir: graphDataDir }, - pgmeta: {}, - studio: {}, + const signIn = await supabase.auth.signInWithPassword({ + email: testEmail, + password: testPassword, }); - await graphStack.start(); - expect(await graphStack.getServiceStatus("pgmeta")).toEqual( - expect.objectContaining({ status: "Healthy" }), + expect(signIn.error).toBeNull(); + expect(signIn.data.user?.email).toBe(testEmail); + expect(signIn.data.session?.access_token).toBeTruthy(); + + const currentUser = await supabase.auth.getUser(); + expect(currentUser.error).toBeNull(); + expect(currentUser.data.user?.email).toBe(testEmail); + + const seeded = await supabase.from("todos").select("*").order("id"); + expect(seeded.error).toBeNull(); + expect(seeded.data).toHaveLength(2); + + const todoTitle = `E2E test todo ${Date.now()}`; + const inserted = await supabase + .from("todos") + .insert({ title: todoTitle }) + .select() + .single(); + expect(inserted.error).toBeNull(); + expect(inserted.data?.title).toBe(todoTitle); + + const updated = await supabase + .from("todos") + .update({ completed: true }) + .eq("title", todoTitle) + .select() + .single(); + expect(updated.error).toBeNull(); + expect(updated.data?.completed).toBe(true); + + const healthyStates = await stack.getStatus(); + expect(healthyStates).toHaveLength(SERVICE_NAMES.length); + expect(healthyStates.every((state) => state.status === "Healthy")).toBe(true); + + const ownedContainers = ownedDockerContainers(apiPort); + expect(ownedContainers.map((container) => container.name).toSorted()).toEqual( + SERVICE_NAMES.map((service) => dockerContainerName(service, apiPort)).toSorted(), ); - expect(await graphStack.getServiceStatus("studio")).toEqual( - expect.objectContaining({ status: "Healthy" }), + for (const service of SERVICE_NAMES) { + const containerName = dockerContainerName(service, apiPort); + const container = ownedContainers.find((candidate) => candidate.name === containerName); + expect(container?.image, `${service} image`).toBe( + dockerImageForService(service, DEFAULT_VERSIONS[service]), + ); + } + + const primaryOwnedNamesBeforeSibling = ownedContainers.map((container) => container.name); + const primaryDbPort = new URL(stack.dbUrl).port; + const primaryIsolationTitle = "isolation-primary"; + const siblingIsolationTitle = "isolation-sibling"; + const primaryIsolationInsert = await supabase + .from("todos") + .insert({ title: primaryIsolationTitle }) + .select() + .single(); + expect(primaryIsolationInsert.error).toBeNull(); + expect(primaryIsolationInsert.data?.title).toBe(primaryIsolationTitle); + + const siblingDataDir = mkdtempSync(join(tmpdir(), "supabase-e2e-docker-sibling-")); + const siblingStartedServices: ReadonlyArray = [...EAGER_SERVICES, "postgrest"]; + let sibling: StackHandle | undefined; + let siblingApiPort: string | undefined; + let siblingJourneyError: unknown; + let siblingDisposeError: unknown; + let siblingContainerCleanupError: unknown; + let siblingDataDirCleanupError: unknown; + try { + sibling = await createStack({ + mode: "docker", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + servicePolicies: { + postgres: "eager", + postgrest: "lazy", + auth: "lazy", + "edge-runtime": "lazy", + realtime: "eager", + storage: "lazy", + imgproxy: "lazy", + mailpit: "eager", + pgmeta: "eager", + studio: "eager", + analytics: "eager", + vector: "eager", + pooler: "eager", + }, + postgres: { dataDir: siblingDataDir }, + postgrest: {}, + auth: {}, + edgeRuntime: {}, + realtime: {}, + storage: {}, + imgproxy: {}, + mailpit: {}, + pgmeta: {}, + studio: {}, + analytics: {}, + vector: {}, + pooler: {}, + }); + const siblingIdentity = new URL(sibling.url).port; + siblingApiPort = siblingIdentity; + await sibling.start(); + + const siblingDbPort = new URL(sibling.dbUrl).port; + expect(siblingIdentity).not.toBe(apiPort); + expect(siblingDbPort).not.toBe(primaryDbPort); + await setupTestTable(parseInt(siblingDbPort)); + const siblingSupabase = createClient(sibling.url, sibling.publishableKey); + await sibling.startService("postgrest"); + + const [primaryBeforeDispose, siblingStates] = await Promise.all([ + stack.getStatus(), + sibling.getStatus(), + ]); + expect(primaryBeforeDispose.map((state) => state.name).toSorted()).toEqual( + [...SERVICE_NAMES].toSorted(), + ); + expect(primaryBeforeDispose.every((state) => state.status === "Healthy")).toBe(true); + expect(primaryBeforeDispose.map(({ name, status }) => ({ name, status }))).toEqual( + healthyStates.map(({ name, status }) => ({ name, status })), + ); + expect(siblingStates.map((state) => state.name).toSorted()).toEqual( + [...SERVICE_NAMES].toSorted(), + ); + expect(siblingStates).toEqual( + expect.arrayContaining( + siblingStartedServices.map((name) => + expect.objectContaining({ name, status: "Healthy" }), + ), + ), + ); + expect(siblingStates).toEqual( + expect.arrayContaining( + LAZY_SERVICES.filter((name) => name !== "postgrest").map((name) => + expect.objectContaining({ name, status: "Dormant" }), + ), + ), + ); + + const siblingIsolationInsert = await siblingSupabase + .from("todos") + .insert({ title: siblingIsolationTitle }) + .select() + .single(); + expect(siblingIsolationInsert.error).toBeNull(); + expect(siblingIsolationInsert.data?.title).toBe(siblingIsolationTitle); + + const [siblingCannotReadPrimary, primaryCannotReadSibling] = await Promise.all([ + siblingSupabase.from("todos").select("title").eq("title", primaryIsolationTitle), + supabase.from("todos").select("title").eq("title", siblingIsolationTitle), + ]); + expect(siblingCannotReadPrimary.error).toBeNull(); + expect(siblingCannotReadPrimary.data).toEqual([]); + expect(primaryCannotReadSibling.error).toBeNull(); + expect(primaryCannotReadSibling.data).toEqual([]); + + const siblingOwnedBeforeDispose = ownedDockerContainers(siblingIdentity); + expect(siblingOwnedBeforeDispose.map((container) => container.name).toSorted()).toEqual( + siblingStartedServices + .map((service) => dockerContainerName(service, siblingIdentity)) + .toSorted(), + ); + } catch (error) { + siblingJourneyError = error; + let siblingStates: ReadonlyArray = []; + let siblingLogs: ReadonlyArray = []; + if (sibling !== undefined) { + const activeSibling = sibling; + [siblingStates, siblingLogs] = await Promise.all([ + activeSibling.getStatus().catch(() => []), + Promise.all( + SERVICE_NAMES.map((service) => + activeSibling.logHistory(service, 10).catch(() => []), + ), + ), + ]); + } + siblingJourneyError = new Error( + `Sibling Docker isolation journey failed: ${String(error)}\nstatus=${JSON.stringify(siblingStates)}\nlogs=${JSON.stringify(siblingLogs)}`, + ); + } finally { + try { + if (sibling !== undefined) { + await sibling.dispose(); + } + } catch (error) { + siblingDisposeError = error; + } + + try { + if (siblingApiPort !== undefined) { + const remaining = ownedDockerContainers(siblingApiPort).map( + (container) => container.name, + ); + if (remaining.length > 0) { + forceRemoveOwnedDockerContainers(remaining); + siblingContainerCleanupError = new Error( + `Sibling owned Docker containers remained after dispose: ${remaining.join(", ")}`, + ); + } + } + } catch (error) { + siblingContainerCleanupError = error; + } + + try { + rmSync(siblingDataDir, { recursive: true, force: true }); + } catch (error) { + siblingDataDirCleanupError = error; + } + } + + const siblingFailures = [ + siblingJourneyError, + siblingDisposeError, + siblingContainerCleanupError, + siblingDataDirCleanupError, + ].filter((failure) => failure !== undefined); + if (siblingFailures.length === 1) { + throw siblingFailures[0]; + } + if (siblingFailures.length > 1) { + throw new AggregateError(siblingFailures, "Sibling Docker isolation cleanup failed"); + } + + expect( + ownedDockerContainers(apiPort) + .map((container) => container.name) + .toSorted(), + ).toEqual(primaryOwnedNamesBeforeSibling.toSorted()); + const primaryAfterSiblingDispose = await supabase + .from("todos") + .select("title") + .eq("title", primaryIsolationTitle) + .single(); + expect(primaryAfterSiblingDispose.error).toBeNull(); + expect(primaryAfterSiblingDispose.data?.title).toBe(primaryIsolationTitle); + const deletedIsolation = await supabase + .from("todos") + .delete() + .eq("title", primaryIsolationTitle); + expect(deletedIsolation.error).toBeNull(); + + const beforeRestart = await stack.getStatus(); + await stack.stop(); + await stack.start(); + const afterRestart = await stack.getStatus(); + expect(afterRestart.map((state) => state.name).toSorted()).toEqual( + beforeRestart.map((state) => state.name).toSorted(), ); - - await graphStack.stop(); - await graphStack.start(); - - expect(await graphStack.getServiceStatus("pgmeta")).toEqual( - expect.objectContaining({ status: "Healthy" }), + expect(afterRestart).toEqual( + expect.arrayContaining( + EAGER_SERVICES.map((name) => expect.objectContaining({ name, status: "Healthy" })), + ), + ); + expect(afterRestart).toEqual( + expect.arrayContaining( + LAZY_SERVICES.map((name) => expect.objectContaining({ name, status: "Stopped" })), + ), ); - expect(await graphStack.getServiceStatus("studio")).toEqual( - expect.objectContaining({ status: "Healthy" }), + await stack.startService("postgrest"); + + const persisted = await supabase.from("todos").select("*").eq("title", todoTitle).single(); + expect(persisted.error).toBeNull(); + expect(persisted.data?.completed).toBe(true); + + const deleted = await supabase.from("todos").delete().eq("title", todoTitle); + expect(deleted.error).toBeNull(); + } catch (error) { + const [states, logs] = await Promise.all([ + stack.getStatus().catch(() => []), + Promise.all( + SERVICE_NAMES.map((service) => stack.logHistory(service, 10).catch(() => [])), + ), + ]); + throw new Error( + `Complete Docker graph journey failed: ${String(error)}\nstatus=${JSON.stringify(states)}\nlogs=${JSON.stringify(logs)}`, ); - } finally { - await graphStack?.dispose(); - rmSync(graphDataDir, { recursive: true, force: true }); } }, );