From 6c77a57277fe877e7b9854ad6864f1c6dbe2bddd Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 30 Sep 2026 19:50:03 +0200 Subject: [PATCH 1/6] test(repo): remove Docker data volumes owned by temporary test state roots Docker database storage keeps one shared volume per state root and daemon, and stack destroy clears only the stack's namespace inside it. Tests that ran a Docker database under a temporary state root deleted that root afterwards, which orphaned the volume for good. Add `removeStateRootVolume` to the stack Docker test fixture. It removes the label-verified volume of one state root. Register it after allocating each temporary Docker state root in the affected CLI and stack integration and E2E tests, so it runs after the stacks are destroyed and before the root is deleted. Route the Commands integration Docker variants through `makeDockerDatabaseRoot`, and replace the stack-cache E2E's marker-based cleanup with the shared helper. --- .../pg-dump.native.integration.test.ts | 6 +- .../stack-catalog-setup.integration.test.ts | 6 +- .../test-db.native.integration.test.ts | 6 +- .../db/diff/diff.stack-cache.e2e.test.ts | 76 ++----------------- .../commands/db/reset/reset.stack.e2e.test.ts | 3 + .../functions/new/new.stack.e2e.test.ts | 3 + .../functions/serve/serve.stack.e2e.test.ts | 3 + .../squash/squash.native.integration.test.ts | 6 +- .../stack/src/Commands.integration.test.ts | 11 ++- packages/stack/src/effect.integration.test.ts | 5 +- packages/stack/tests/docker-fixture.ts | 65 +++++++++------- packages/stack/tests/whole-stack/fixture.ts | 3 + 12 files changed, 89 insertions(+), 104 deletions(-) diff --git a/apps/cli/src/command-internal/pg-dump.native.integration.test.ts b/apps/cli/src/command-internal/pg-dump.native.integration.test.ts index 1b163a63d6..72a54b79ef 100644 --- a/apps/cli/src/command-internal/pg-dump.native.integration.test.ts +++ b/apps/cli/src/command-internal/pg-dump.native.integration.test.ts @@ -13,6 +13,7 @@ import { BundledPostgresClient } from "./bundled-postgres-client.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { mockOutput } from "../../tests/helpers/mocks.ts"; import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; +import { removeStateRootVolume } from "../../../../packages/stack/tests/docker-fixture.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); @@ -24,10 +25,13 @@ describe("managed pg_dump against a live stack", { timeout: 180_000 }, () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "cli-pg-dump-" }); + const stateRoot = `${root}/stacks`; + if (runtime === "docker") + yield* Effect.addFinalizer(() => removeStateRootVolume(stateRoot)); const api = yield* StackApi; const stack = yield* api.create({ projectRoot: root, - stateRoot: `${root}/stacks`, + stateRoot, cacheRoot: `${root}/cache`, runtime, }); diff --git a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts index 8601b9b809..e363307e2e 100644 --- a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts +++ b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts @@ -21,6 +21,7 @@ import { mockOutput } from "../../tests/helpers/mocks.ts"; import type { Command } from "@supabase/stack/commands"; import { stackCatalogSetupLayer, StackCatalogSetup } from "./stack-catalog-setup.ts"; import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; +import { removeStateRootVolume } from "../../../../packages/stack/tests/docker-fixture.ts"; const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; const jwtSecret = "stack-catalog-setup-integration-secret"; @@ -36,6 +37,9 @@ describe("stack catalog setup", { timeout: 180_000 }, () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: `stack-catalog-${runtime}-` }); + const stateRoot = `${root}/state`; + if (runtime === "docker") + yield* Effect.addFinalizer(() => removeStateRootVolume(stateRoot)); yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); yield* fs.writeFileString( `${root}/supabase/roles.sql`, @@ -43,7 +47,7 @@ describe("stack catalog setup", { timeout: 180_000 }, () => { ); const stack = yield* create({ projectRoot: root, - stateRoot: `${root}/state`, + stateRoot, cacheRoot, runtime, }); diff --git a/apps/cli/src/command-internal/test-db.native.integration.test.ts b/apps/cli/src/command-internal/test-db.native.integration.test.ts index d8f52694fb..8f02e26977 100644 --- a/apps/cli/src/command-internal/test-db.native.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.native.integration.test.ts @@ -22,6 +22,7 @@ import { stackBackendLayer } from "./stack-backend.ts"; import { testDb } from "./test-db.handler.ts"; import { runTestDbCommand } from "./test-db.command-handler.ts"; import { DockerRun } from "./docker-run.service.ts"; +import { removeStateRootVolume } from "../../../../packages/stack/tests/docker-fixture.ts"; import { StackError } from "@supabase/stack/effect"; import type { InitializationCommandOptions, PostgresCommandOptions } from "@supabase/stack/effect"; import type { Stack } from "@supabase/stack/effect"; @@ -70,11 +71,14 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { "SELECT plan(1); SELECT fail('managed pgTAP failure'); SELECT * FROM finish();\n", ); + const stateRoot = `${root}/stacks`; + if (runtime === "docker") + yield* Effect.addFinalizer(() => removeStateRootVolume(stateRoot)); const api = yield* StackApi; const stack = yield* Effect.acquireRelease( api.create({ projectRoot: root, - stateRoot: `${root}/stacks`, + stateRoot, cacheRoot: `${root}/cache`, runtime, }), diff --git a/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts b/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts index c0518adc45..ee62e24534 100644 --- a/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts +++ b/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts @@ -1,11 +1,12 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Data, Effect, Exit, FileSystem, Path, Predicate, Redacted, Schema, Stream } from "effect"; +import { Effect, FileSystem, Path, Redacted } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { create as createStack } from "@supabase/stack/effect"; import { tmpdir } from "node:os"; import { runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; +import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; +import { removeStateRootVolume } from "../../../../../../packages/stack/tests/docker-fixture.ts"; const COMMAND_TIMEOUT_MS = 8 * 60_000; const TEST_TIMEOUT_MS = COMMAND_TIMEOUT_MS * 4 + 2 * 60_000; @@ -31,82 +32,20 @@ enabled = false enabled = false `; -const storageMarker = Schema.Struct({ - backend: Schema.Literals(["docker", "host"]), - volume: Schema.optionalKey(Schema.String), -}); - -class DockerCleanupError extends Data.TaggedError("DockerCleanupError")<{ - readonly message: string; -}> {} - -const removeDockerVolume = Effect.fn("DbDiffStackCacheE2e.removeDockerVolume")((volume: string) => - Effect.scoped( - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner.spawn( - ChildProcess.make("docker", ["volume", "rm", volume], { stdin: "ignore" }), - ); - const [stderr, code] = yield* Effect.all( - [ - child.stdout.pipe(Stream.runDrain), - child.stderr.pipe(Stream.decodeText, Stream.mkString), - child.exitCode, - ], - { concurrency: "unbounded" }, - ).pipe(Effect.map(([, stderr, code]) => [stderr, code] as const)); - if (Number(code) !== 0 && !/no such volume/iu.test(stderr)) - return yield* new DockerCleanupError({ - message: `docker volume rm ${volume} failed: ${stderr.trim() || `exit ${code}`}`, - }); - }), - ), -); - const composeStack = Effect.fn("DbDiffStackCacheE2e.composeStack")(function* ( root: string, home: string, runtime: "native" | "docker", ) { + const stateRoot = `${home}/stacks`; const stack = yield* createStack({ projectRoot: root, - stateRoot: `${home}/stacks`, + stateRoot, cacheRoot: `${home}/cache/stack`, runtime, }); - let databaseId: string | undefined; - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const marker = yield* Effect.gen(function* () { - if (runtime !== "docker" || databaseId === undefined) return undefined; - const fs = yield* FileSystem.FileSystem; - const markerText = yield* fs - .readFileString( - `${home}/stacks/${stack.id}/data/${databaseId}/.supabase-database-storage.json`, - ) - .pipe( - Effect.catchIf( - (cause) => Predicate.isTagged(cause.reason, "NotFound"), - () => Effect.void, - ), - ); - if (markerText === undefined) return undefined; - return yield* Schema.decodeEffect(Schema.fromJsonString(storageMarker))(markerText); - }).pipe(Effect.exit); - const destroyed = yield* stack.destroy.pipe(Effect.exit); - const volume = - Exit.isSuccess(marker) && marker.value !== undefined && marker.value.backend === "docker" - ? marker.value.volume - : undefined; - const removed = - volume === undefined - ? Exit.succeed(undefined) - : yield* removeDockerVolume(volume).pipe(Effect.exit); - if (Exit.isFailure(destroyed)) return yield* Effect.failCause(destroyed.cause); - if (Exit.isFailure(marker)) return yield* Effect.failCause(marker.cause); - if (Exit.isFailure(removed)) return yield* Effect.failCause(removed.cause); - }).pipe(Effect.catchCause((cause) => Effect.die(cause))), - ); + if (runtime === "docker") yield* Effect.addFinalizer(() => removeStateRootVolume(stateRoot)); + yield* Effect.addFinalizer(() => destroyTestStack(stack)); const [database] = yield* stack.composition.supabase([ { service: "database", @@ -120,7 +59,6 @@ const composeStack = Effect.fn("DbDiffStackCacheE2e.composeStack")(function* ( }, ]); if (database === undefined) return yield* Effect.die("database composition missing"); - databaseId = database.id; yield* stack.composition.start; return stack; }); diff --git a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts index f86b7e44f8..eca697ac74 100644 --- a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts +++ b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts @@ -8,6 +8,7 @@ import { tmpdir } from "node:os"; import { runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; +import { removeStateRootVolume } from "../../../../../../packages/stack/tests/docker-fixture.ts"; const COMMAND_TIMEOUT_MS = 8 * 60_000; const TEST_TIMEOUT_MS = COMMAND_TIMEOUT_MS + 2 * 60_000; @@ -164,6 +165,8 @@ describe("supabase db reset (stack e2e)", () => { const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: `db-reset-${runtime}-` }); const home = yield* fs.makeTempDirectoryScoped({ prefix: `db-reset-home-${runtime}-` }); + if (runtime === "docker") + yield* Effect.addFinalizer(() => removeStateRootVolume(`${home}/stacks`)); yield* writeFixture(root, fs, path); yield* fs.makeDirectory(path.join(home, "cache"), { recursive: true }); yield* fs.makeDirectory(path.join(tmpdir(), "supabase-stack-artifacts"), { diff --git a/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts b/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts index d3c24be7c5..6cf2ed8995 100644 --- a/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts +++ b/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts @@ -7,6 +7,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { homedir } from "node:os"; import { makeTempHome, runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; +import { removeStateRootVolume } from "../../../../../../packages/stack/tests/docker-fixture.ts"; const nativeSupported = (process.platform === "linux" && (process.arch === "x64" || process.arch === "arm64")) || @@ -78,6 +79,8 @@ describe("functions new (stack e2e)", () => { }); const home = makeTempHome(); yield* Effect.addFinalizer(() => Effect.sync(() => home[Symbol.dispose]())); + if (runtime === "docker") + yield* Effect.addFinalizer(() => removeStateRootVolume(path.join(home.dir, "stacks"))); yield* fs.makeDirectory(path.join(projectDir, "supabase"), { recursive: true }); yield* fs.writeFileString( path.join(projectDir, "supabase", "config.toml"), diff --git a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts index 5bc41babe2..461af4012f 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts @@ -8,6 +8,7 @@ import { homedir, tmpdir } from "node:os"; import { spawnSupabase } from "../../../../tests/helpers/cli.ts"; import { generateGoJwt } from "../../../command-internal/go-jwt.ts"; import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; +import { removeStateRootVolume } from "../../../../../../packages/stack/tests/docker-fixture.ts"; const jwtSecret = "functions-serve-stack-e2e-secret-at-least-32-characters"; const nativeSupported = @@ -38,6 +39,8 @@ const fixture = Effect.fn("FunctionsServeE2e.fixture")(function* ( prefix: `functions-serve-${runtime}-`, }); const home = yield* fs.makeTempDirectoryScoped({ prefix: "functions-serve-home-" }); + if (runtime === "docker") + yield* Effect.addFinalizer(() => removeStateRootVolume(path.join(home, "stacks"))); const functionsRoot = path.join(root, "supabase", "functions"); yield* fs.makeDirectory(path.join(functionsRoot, "hello"), { recursive: true }); yield* fs.writeFileString( diff --git a/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts index db17d0494a..1c37d4f8bf 100644 --- a/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts @@ -29,6 +29,7 @@ import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { migrationSquash } from "./squash.handler.ts"; import type { MigrationSquashFlags } from "./squash.command.ts"; import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; +import { removeStateRootVolume } from "../../../../../../packages/stack/tests/docker-fixture.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); @@ -132,10 +133,13 @@ describe("managed migration squash", { timeout: 180_000 }, () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const stateRoot = path.join(root, "stacks"); + if (runtime === "docker") + yield* Effect.addFinalizer(() => removeStateRootVolume(stateRoot)); const api = yield* StackApi; const current = yield* api.create({ projectRoot: root, - stateRoot: path.join(root, "stacks"), + stateRoot, cacheRoot: path.join(root, "cache"), runtime, }); diff --git a/packages/stack/src/Commands.integration.test.ts b/packages/stack/src/Commands.integration.test.ts index 7e6d3b7a36..21d81d129a 100644 --- a/packages/stack/src/Commands.integration.test.ts +++ b/packages/stack/src/Commands.integration.test.ts @@ -24,6 +24,7 @@ import type { PgProveOptions, PostgresCommand } from "./Commands.ts"; import { makeDatabase } from "./services/Database.ts"; import { makeService } from "./Service.ts"; import { bindTcp, serveTcp } from "./Proxy.ts"; +import { makeDockerDatabaseRoot } from "../tests/docker-fixture.ts"; const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; @@ -77,8 +78,11 @@ describe("finite PostgreSQL commands", { timeout: 180_000 }, () => { Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-tools-" }); const stackId = `tools-integration-${randomUUID()}`; + const root = + runtime === "docker" + ? yield* makeDockerDatabaseRoot("stack-tools-", stackId) + : yield* fs.makeTempDirectoryScoped({ prefix: "stack-tools-" }); const database = yield* makeDatabase({ root, cacheRoot, @@ -224,8 +228,11 @@ describe("finite PostgreSQL commands", { timeout: 180_000 }, () => { Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: `stack-pgprove-${major}-` }); const stackId = `tools-pgprove-${runtime}-${major}-${randomUUID()}`; + const root = + runtime === "docker" + ? yield* makeDockerDatabaseRoot(`stack-pgprove-${major}-`, stackId) + : yield* fs.makeTempDirectoryScoped({ prefix: `stack-pgprove-${major}-` }); const database = yield* makeDatabase({ root, cacheRoot, diff --git a/packages/stack/src/effect.integration.test.ts b/packages/stack/src/effect.integration.test.ts index 723bb24fc1..b76727d2e6 100644 --- a/packages/stack/src/effect.integration.test.ts +++ b/packages/stack/src/effect.integration.test.ts @@ -30,6 +30,7 @@ import { launchHost } from "./HostProcess.ts"; import * as PromiseApi from "./index.ts"; import * as State from "./State.ts"; import { assertOwnerExited, watchLeaseRelease } from "../tests/owner.ts"; +import { removeStateRootVolume } from "../tests/docker-fixture.ts"; import { foreignRelease } from "../tests/release-owner-fixture.ts"; import { destroyTestStack } from "../tests/stack-cleanup.ts"; import { deriveStackId, resolveStackIdentity } from "./identity/Identity.ts"; @@ -220,9 +221,11 @@ const resetDataStory = (runtime: "native" | "docker") => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: `stack-reset-data-${runtime}-` }); + const stateRoot = `${root}/state`; + if (runtime === "docker") yield* Effect.addFinalizer(() => removeStateRootVolume(stateRoot)); const options = { projectRoot: root, - stateRoot: `${root}/state`, + stateRoot, cacheRoot: `${tmpdir()}/supabase-stack-artifacts`, runtime, } satisfies Parameters[0]; diff --git a/packages/stack/tests/docker-fixture.ts b/packages/stack/tests/docker-fixture.ts index f986eb7931..9dcd510178 100644 --- a/packages/stack/tests/docker-fixture.ts +++ b/packages/stack/tests/docker-fixture.ts @@ -24,17 +24,14 @@ export const runDocker = Effect.fn("DockerTest.runDocker")((args: ReadonlyArray< ), ); -/** Allocates the documented state-root layout used by Docker database fixtures. */ -export const makeDockerDatabaseRoot = Effect.fn("DockerTest.makeDatabaseRoot")( - (prefix: string, stackId = "catalog-test") => +/** Removes the shared database volume for a state root after verifying its ownership labels. */ +export const removeStateRootVolume = Effect.fn("DockerTest.removeStateRootVolume")( + (stateRoot: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; - const temporaryRoot = yield* fs.makeTempDirectoryScoped({ prefix }); - const root = `${temporaryRoot}/state/${stackId}/data`; - yield* fs.makeDirectory(root, { recursive: true }); - const stateRoot = yield* fs.realPath(path.dirname(path.dirname(root))); + if (!(yield* fs.exists(stateRoot))) return; + const canonicalStateRoot = yield* fs.realPath(stateRoot); const daemon = yield* runDocker(["info", "--format", "{{.ID}}"]).pipe( Effect.flatMap((result) => result.code === 0 @@ -43,33 +40,45 @@ export const makeDockerDatabaseRoot = Effect.fn("DockerTest.makeDatabaseRoot")( ), ); const stateDigest = yield* crypto - .digest("SHA-256", new TextEncoder().encode(`${stateRoot}\0${daemon}`)) + .digest("SHA-256", new TextEncoder().encode(`${canonicalStateRoot}\0${daemon}`)) .pipe( Effect.map((bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""), ), ); const volume = volumeNameFor(stateDigest); + const inspected = yield* runDocker([ + "volume", + "inspect", + "--format", + '{{ index .Labels "com.supabase.stack-managed" }}|{{ index .Labels "com.supabase.stack-state-root" }}', + volume, + ]); + if (inspected.code !== 0) { + if (/no such volume|not found/iu.test(inspected.output)) return; + return yield* Effect.die(`Docker volume inspect failed: ${inspected.output}`); + } + const [managed, labeledState] = inspected.output.trim().split("|"); + if (managed !== "true" || labeledState !== stateDigest) + return yield* Effect.die("Docker fixture volume identity did not match its state root"); + const removed = yield* runDocker(["volume", "rm", volume]); + if (removed.code !== 0 && !/no such volume|not found/iu.test(removed.output)) + return yield* Effect.die(`Docker volume cleanup failed: ${removed.output}`); + }).pipe(Effect.catchCause(Effect.die)), +); + +/** Allocates the documented state-root layout used by Docker database fixtures. */ +export const makeDockerDatabaseRoot = Effect.fn("DockerTest.makeDatabaseRoot")( + (prefix: string, stackId = "catalog-test") => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryRoot = yield* fs.makeTempDirectoryScoped({ prefix }); + const root = `${temporaryRoot}/state/${stackId}/data`; + yield* fs.makeDirectory(root, { recursive: true }); + const stateRoot = path.dirname(path.dirname(root)); yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const inspected = yield* runDocker([ - "volume", - "inspect", - "--format", - '{{ index .Labels "com.supabase.stack-managed" }}|{{ index .Labels "com.supabase.stack-state-root" }}', - volume, - ]); - if (inspected.code !== 0) { - if (/no such volume|not found/iu.test(inspected.output)) return; - return yield* Effect.die(`Docker volume inspect failed: ${inspected.output}`); - } - const [managed, labeledState] = inspected.output.trim().split("|"); - if (managed !== "true" || labeledState !== stateDigest) - return yield* Effect.die("Docker fixture volume identity did not match its state root"); - const removed = yield* runDocker(["volume", "rm", volume]); - if (removed.code !== 0 && !/no such volume|not found/iu.test(removed.output)) - return yield* Effect.die(`Docker volume cleanup failed: ${removed.output}`); - }).pipe( + removeStateRootVolume(stateRoot).pipe( Effect.andThen( Effect.gen(function* () { if (yield* fs.exists(root)) yield* cleanupDockerRoot(root); diff --git a/packages/stack/tests/whole-stack/fixture.ts b/packages/stack/tests/whole-stack/fixture.ts index 98e048cf87..bce553c5e7 100644 --- a/packages/stack/tests/whole-stack/fixture.ts +++ b/packages/stack/tests/whole-stack/fixture.ts @@ -19,6 +19,7 @@ import { create, type Stack } from "../../src/effect.ts"; import type { Observation } from "../../src/Rpc.ts"; import { vectorAnalyticsConfig } from "./analytics.ts"; import { cleanupDockerRoot } from "../docker-cleanup.ts"; +import { removeStateRootVolume } from "../docker-fixture.ts"; import { destroyTestStack } from "../stack-cleanup.ts"; type AnyService = Effect.Success[number]; @@ -120,6 +121,8 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => const functionsRoot = `${root}/functions`; const storageRoot = `${root}/storage`; const vectorConfigPath = `${root}/vector.yaml`; + if (runtime === "docker") + yield* Effect.addFinalizer(() => removeStateRootVolume(`${root}/state`)); yield* fs.makeDirectory(`${functionsRoot}/hello`, { recursive: true }); yield* fs.makeDirectory(storageRoot, { recursive: true }); yield* fs.writeFileString( From 1e5d28229a444f8c13a8d0e9aba900d809eab7a4 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 30 Sep 2026 20:07:47 +0200 Subject: [PATCH 2/6] test(cli): import the state-root volume cleanup through the stack cleanup helper --- .../src/command-internal/pg-dump.native.integration.test.ts | 3 +-- .../command-internal/stack-catalog-setup.integration.test.ts | 3 +-- .../src/command-internal/test-db.native.integration.test.ts | 3 +-- apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts | 3 +-- apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts | 3 +-- apps/cli/src/commands/functions/new/new.stack.e2e.test.ts | 2 +- apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts | 3 +-- .../migration/squash/squash.native.integration.test.ts | 3 +-- apps/cli/tests/helpers/stack-cleanup.ts | 2 ++ 9 files changed, 10 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/command-internal/pg-dump.native.integration.test.ts b/apps/cli/src/command-internal/pg-dump.native.integration.test.ts index 72a54b79ef..79eb485486 100644 --- a/apps/cli/src/command-internal/pg-dump.native.integration.test.ts +++ b/apps/cli/src/command-internal/pg-dump.native.integration.test.ts @@ -12,8 +12,7 @@ import { DockerRun } from "./docker-run.service.ts"; import { BundledPostgresClient } from "./bundled-postgres-client.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { mockOutput } from "../../tests/helpers/mocks.ts"; -import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; -import { removeStateRootVolume } from "../../../../packages/stack/tests/docker-fixture.ts"; +import { destroyTestStack, removeStateRootVolume } from "../../tests/helpers/stack-cleanup.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); diff --git a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts index e363307e2e..5ddecfca0a 100644 --- a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts +++ b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts @@ -20,8 +20,7 @@ import { postgres } from "@supabase/stack/commands"; import { mockOutput } from "../../tests/helpers/mocks.ts"; import type { Command } from "@supabase/stack/commands"; import { stackCatalogSetupLayer, StackCatalogSetup } from "./stack-catalog-setup.ts"; -import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; -import { removeStateRootVolume } from "../../../../packages/stack/tests/docker-fixture.ts"; +import { destroyTestStack, removeStateRootVolume } from "../../tests/helpers/stack-cleanup.ts"; const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; const jwtSecret = "stack-catalog-setup-integration-secret"; diff --git a/apps/cli/src/command-internal/test-db.native.integration.test.ts b/apps/cli/src/command-internal/test-db.native.integration.test.ts index 8f02e26977..953e066286 100644 --- a/apps/cli/src/command-internal/test-db.native.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.native.integration.test.ts @@ -22,12 +22,11 @@ import { stackBackendLayer } from "./stack-backend.ts"; import { testDb } from "./test-db.handler.ts"; import { runTestDbCommand } from "./test-db.command-handler.ts"; import { DockerRun } from "./docker-run.service.ts"; -import { removeStateRootVolume } from "../../../../packages/stack/tests/docker-fixture.ts"; import { StackError } from "@supabase/stack/effect"; import type { InitializationCommandOptions, PostgresCommandOptions } from "@supabase/stack/effect"; import type { Stack } from "@supabase/stack/effect"; import type { InitializationCommand, PostgresCommand } from "@supabase/stack/commands"; -import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; +import { destroyTestStack, removeStateRootVolume } from "../../tests/helpers/stack-cleanup.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); diff --git a/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts b/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts index ee62e24534..169118506c 100644 --- a/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts +++ b/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts @@ -5,8 +5,7 @@ import { FetchHttpClient } from "effect/unstable/http"; import { create as createStack } from "@supabase/stack/effect"; import { tmpdir } from "node:os"; import { runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; -import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; -import { removeStateRootVolume } from "../../../../../../packages/stack/tests/docker-fixture.ts"; +import { destroyTestStack, removeStateRootVolume } from "../../../../tests/helpers/stack-cleanup.ts"; const COMMAND_TIMEOUT_MS = 8 * 60_000; const TEST_TIMEOUT_MS = COMMAND_TIMEOUT_MS * 4 + 2 * 60_000; diff --git a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts index eca697ac74..8bd9e1a46a 100644 --- a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts +++ b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts @@ -7,8 +7,7 @@ import { create as createStack } from "@supabase/stack/effect"; import { tmpdir } from "node:os"; import { runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; -import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; -import { removeStateRootVolume } from "../../../../../../packages/stack/tests/docker-fixture.ts"; +import { destroyTestStack, removeStateRootVolume } from "../../../../tests/helpers/stack-cleanup.ts"; const COMMAND_TIMEOUT_MS = 8 * 60_000; const TEST_TIMEOUT_MS = COMMAND_TIMEOUT_MS + 2 * 60_000; diff --git a/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts b/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts index 6cf2ed8995..f78f6abe3e 100644 --- a/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts +++ b/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts @@ -7,7 +7,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { homedir } from "node:os"; import { makeTempHome, runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; -import { removeStateRootVolume } from "../../../../../../packages/stack/tests/docker-fixture.ts"; +import { removeStateRootVolume } from "../../../../tests/helpers/stack-cleanup.ts"; const nativeSupported = (process.platform === "linux" && (process.arch === "x64" || process.arch === "arm64")) || diff --git a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts index 461af4012f..5128b4ef92 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts @@ -7,8 +7,7 @@ import { homedir, tmpdir } from "node:os"; import { spawnSupabase } from "../../../../tests/helpers/cli.ts"; import { generateGoJwt } from "../../../command-internal/go-jwt.ts"; -import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; -import { removeStateRootVolume } from "../../../../../../packages/stack/tests/docker-fixture.ts"; +import { destroyTestStack, removeStateRootVolume } from "../../../../tests/helpers/stack-cleanup.ts"; const jwtSecret = "functions-serve-stack-e2e-secret-at-least-32-characters"; const nativeSupported = diff --git a/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts index 1c37d4f8bf..bd4bc4d291 100644 --- a/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts @@ -28,8 +28,7 @@ import { BundledPostgresClient } from "../../../command-internal/bundled-postgre import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { migrationSquash } from "./squash.handler.ts"; import type { MigrationSquashFlags } from "./squash.command.ts"; -import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; -import { removeStateRootVolume } from "../../../../../../packages/stack/tests/docker-fixture.ts"; +import { destroyTestStack, removeStateRootVolume } from "../../../../tests/helpers/stack-cleanup.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); diff --git a/apps/cli/tests/helpers/stack-cleanup.ts b/apps/cli/tests/helpers/stack-cleanup.ts index c5326195a5..72733354c3 100644 --- a/apps/cli/tests/helpers/stack-cleanup.ts +++ b/apps/cli/tests/helpers/stack-cleanup.ts @@ -2,6 +2,8 @@ import type { Stack } from "@supabase/stack/effect"; import { Cause, Effect, Exit } from "effect"; import type { StackApi } from "../../src/command-internal/stack-api.ts"; +export { removeStateRootVolume } from "../../../../packages/stack/tests/docker-fixture.ts"; + /** Destroys a test stack, failing the test when teardown fails. */ export const destroyTestStack = (stack: Stack): Effect.Effect => stack.destroy.pipe(Effect.orDie); From 327d571a18db479f222200cfd4e27dfc7dbe15af Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 30 Sep 2026 20:16:45 +0200 Subject: [PATCH 3/6] test(cli): format merged stack cleanup imports --- apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts | 5 ++++- apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts | 5 ++++- .../cli/src/commands/functions/serve/serve.stack.e2e.test.ts | 5 ++++- .../migration/squash/squash.native.integration.test.ts | 5 ++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts b/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts index 169118506c..3018b6b8ab 100644 --- a/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts +++ b/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts @@ -5,7 +5,10 @@ import { FetchHttpClient } from "effect/unstable/http"; import { create as createStack } from "@supabase/stack/effect"; import { tmpdir } from "node:os"; import { runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; -import { destroyTestStack, removeStateRootVolume } from "../../../../tests/helpers/stack-cleanup.ts"; +import { + destroyTestStack, + removeStateRootVolume, +} from "../../../../tests/helpers/stack-cleanup.ts"; const COMMAND_TIMEOUT_MS = 8 * 60_000; const TEST_TIMEOUT_MS = COMMAND_TIMEOUT_MS * 4 + 2 * 60_000; diff --git a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts index 8bd9e1a46a..ee8e1483b6 100644 --- a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts +++ b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts @@ -7,7 +7,10 @@ import { create as createStack } from "@supabase/stack/effect"; import { tmpdir } from "node:os"; import { runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; -import { destroyTestStack, removeStateRootVolume } from "../../../../tests/helpers/stack-cleanup.ts"; +import { + destroyTestStack, + removeStateRootVolume, +} from "../../../../tests/helpers/stack-cleanup.ts"; const COMMAND_TIMEOUT_MS = 8 * 60_000; const TEST_TIMEOUT_MS = COMMAND_TIMEOUT_MS + 2 * 60_000; diff --git a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts index 5128b4ef92..389c377a8e 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts @@ -7,7 +7,10 @@ import { homedir, tmpdir } from "node:os"; import { spawnSupabase } from "../../../../tests/helpers/cli.ts"; import { generateGoJwt } from "../../../command-internal/go-jwt.ts"; -import { destroyTestStack, removeStateRootVolume } from "../../../../tests/helpers/stack-cleanup.ts"; +import { + destroyTestStack, + removeStateRootVolume, +} from "../../../../tests/helpers/stack-cleanup.ts"; const jwtSecret = "functions-serve-stack-e2e-secret-at-least-32-characters"; const nativeSupported = diff --git a/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts index bd4bc4d291..0cb004913d 100644 --- a/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts @@ -28,7 +28,10 @@ import { BundledPostgresClient } from "../../../command-internal/bundled-postgre import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { migrationSquash } from "./squash.handler.ts"; import type { MigrationSquashFlags } from "./squash.command.ts"; -import { destroyTestStack, removeStateRootVolume } from "../../../../tests/helpers/stack-cleanup.ts"; +import { + destroyTestStack, + removeStateRootVolume, +} from "../../../../tests/helpers/stack-cleanup.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); From 830eebe3e3883765cc84d34bca8265879b512cf3 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 1 Oct 2026 11:26:16 +0200 Subject: [PATCH 4/6] test(repo): remove test Docker volumes once per run by creation label Replace the per-test volume finalizers with one mechanism. When SUPABASE_STACK_TEST_RUN is set, Docker database storage labels the volumes it creates with com.supabase.stack-test-run=. A shared vitest globalSetup in the stack and CLI integration and E2E projects generates the id, and its teardown removes exactly the stack-managed volumes carrying it. Volume naming, namespaces, cache and destroy behaviour are unchanged, and nothing changes when the variable is unset. --- .../pg-dump.native.integration.test.ts | 7 +- .../stack-catalog-setup.integration.test.ts | 7 +- .../test-db.native.integration.test.ts | 7 +- .../db/diff/diff.stack-cache.e2e.test.ts | 9 +- .../commands/db/reset/reset.stack.e2e.test.ts | 7 +- .../functions/new/new.stack.e2e.test.ts | 3 - .../functions/serve/serve.stack.e2e.test.ts | 7 +- .../squash/squash.native.integration.test.ts | 10 +-- apps/cli/tests/helpers/mocks.ts | 10 ++- apps/cli/tests/helpers/stack-cleanup.ts | 2 - apps/cli/vitest.config.ts | 2 + packages/stack/ARCHITECTURE.md | 2 +- packages/stack/src/effect.integration.test.ts | 5 +- .../DockerDatabaseStorage.integration.test.ts | 57 +++++++++++++ .../src/storage/DockerDatabaseStorage.ts | 68 ++++++++++----- .../tests/docker-fixture.integration.test.ts | 4 +- packages/stack/tests/docker-fixture.ts | 65 ++------------ .../docker-volume-run.integration.test.ts | 64 ++++++++++++++ packages/stack/tests/docker-volume-run.ts | 85 +++++++++++++++++++ packages/stack/tests/whole-stack/fixture.ts | 3 - packages/stack/vitest.config.ts | 2 + 21 files changed, 292 insertions(+), 134 deletions(-) create mode 100644 packages/stack/tests/docker-volume-run.integration.test.ts create mode 100644 packages/stack/tests/docker-volume-run.ts diff --git a/apps/cli/src/command-internal/pg-dump.native.integration.test.ts b/apps/cli/src/command-internal/pg-dump.native.integration.test.ts index 79eb485486..1b163a63d6 100644 --- a/apps/cli/src/command-internal/pg-dump.native.integration.test.ts +++ b/apps/cli/src/command-internal/pg-dump.native.integration.test.ts @@ -12,7 +12,7 @@ import { DockerRun } from "./docker-run.service.ts"; import { BundledPostgresClient } from "./bundled-postgres-client.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { mockOutput } from "../../tests/helpers/mocks.ts"; -import { destroyTestStack, removeStateRootVolume } from "../../tests/helpers/stack-cleanup.ts"; +import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); @@ -24,13 +24,10 @@ describe("managed pg_dump against a live stack", { timeout: 180_000 }, () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "cli-pg-dump-" }); - const stateRoot = `${root}/stacks`; - if (runtime === "docker") - yield* Effect.addFinalizer(() => removeStateRootVolume(stateRoot)); const api = yield* StackApi; const stack = yield* api.create({ projectRoot: root, - stateRoot, + stateRoot: `${root}/stacks`, cacheRoot: `${root}/cache`, runtime, }); diff --git a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts index 5ddecfca0a..8601b9b809 100644 --- a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts +++ b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts @@ -20,7 +20,7 @@ import { postgres } from "@supabase/stack/commands"; import { mockOutput } from "../../tests/helpers/mocks.ts"; import type { Command } from "@supabase/stack/commands"; import { stackCatalogSetupLayer, StackCatalogSetup } from "./stack-catalog-setup.ts"; -import { destroyTestStack, removeStateRootVolume } from "../../tests/helpers/stack-cleanup.ts"; +import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; const jwtSecret = "stack-catalog-setup-integration-secret"; @@ -36,9 +36,6 @@ describe("stack catalog setup", { timeout: 180_000 }, () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: `stack-catalog-${runtime}-` }); - const stateRoot = `${root}/state`; - if (runtime === "docker") - yield* Effect.addFinalizer(() => removeStateRootVolume(stateRoot)); yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); yield* fs.writeFileString( `${root}/supabase/roles.sql`, @@ -46,7 +43,7 @@ describe("stack catalog setup", { timeout: 180_000 }, () => { ); const stack = yield* create({ projectRoot: root, - stateRoot, + stateRoot: `${root}/state`, cacheRoot, runtime, }); diff --git a/apps/cli/src/command-internal/test-db.native.integration.test.ts b/apps/cli/src/command-internal/test-db.native.integration.test.ts index 953e066286..d8f52694fb 100644 --- a/apps/cli/src/command-internal/test-db.native.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.native.integration.test.ts @@ -26,7 +26,7 @@ import { StackError } from "@supabase/stack/effect"; import type { InitializationCommandOptions, PostgresCommandOptions } from "@supabase/stack/effect"; import type { Stack } from "@supabase/stack/effect"; import type { InitializationCommand, PostgresCommand } from "@supabase/stack/commands"; -import { destroyTestStack, removeStateRootVolume } from "../../tests/helpers/stack-cleanup.ts"; +import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); @@ -70,14 +70,11 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { "SELECT plan(1); SELECT fail('managed pgTAP failure'); SELECT * FROM finish();\n", ); - const stateRoot = `${root}/stacks`; - if (runtime === "docker") - yield* Effect.addFinalizer(() => removeStateRootVolume(stateRoot)); const api = yield* StackApi; const stack = yield* Effect.acquireRelease( api.create({ projectRoot: root, - stateRoot, + stateRoot: `${root}/stacks`, cacheRoot: `${root}/cache`, runtime, }), diff --git a/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts b/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts index 3018b6b8ab..37603ac7da 100644 --- a/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts +++ b/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts @@ -5,10 +5,7 @@ import { FetchHttpClient } from "effect/unstable/http"; import { create as createStack } from "@supabase/stack/effect"; import { tmpdir } from "node:os"; import { runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; -import { - destroyTestStack, - removeStateRootVolume, -} from "../../../../tests/helpers/stack-cleanup.ts"; +import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; const COMMAND_TIMEOUT_MS = 8 * 60_000; const TEST_TIMEOUT_MS = COMMAND_TIMEOUT_MS * 4 + 2 * 60_000; @@ -39,14 +36,12 @@ const composeStack = Effect.fn("DbDiffStackCacheE2e.composeStack")(function* ( home: string, runtime: "native" | "docker", ) { - const stateRoot = `${home}/stacks`; const stack = yield* createStack({ projectRoot: root, - stateRoot, + stateRoot: `${home}/stacks`, cacheRoot: `${home}/cache/stack`, runtime, }); - if (runtime === "docker") yield* Effect.addFinalizer(() => removeStateRootVolume(stateRoot)); yield* Effect.addFinalizer(() => destroyTestStack(stack)); const [database] = yield* stack.composition.supabase([ { diff --git a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts index ee8e1483b6..f86b7e44f8 100644 --- a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts +++ b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts @@ -7,10 +7,7 @@ import { create as createStack } from "@supabase/stack/effect"; import { tmpdir } from "node:os"; import { runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; -import { - destroyTestStack, - removeStateRootVolume, -} from "../../../../tests/helpers/stack-cleanup.ts"; +import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; const COMMAND_TIMEOUT_MS = 8 * 60_000; const TEST_TIMEOUT_MS = COMMAND_TIMEOUT_MS + 2 * 60_000; @@ -167,8 +164,6 @@ describe("supabase db reset (stack e2e)", () => { const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: `db-reset-${runtime}-` }); const home = yield* fs.makeTempDirectoryScoped({ prefix: `db-reset-home-${runtime}-` }); - if (runtime === "docker") - yield* Effect.addFinalizer(() => removeStateRootVolume(`${home}/stacks`)); yield* writeFixture(root, fs, path); yield* fs.makeDirectory(path.join(home, "cache"), { recursive: true }); yield* fs.makeDirectory(path.join(tmpdir(), "supabase-stack-artifacts"), { diff --git a/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts b/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts index f78f6abe3e..d3c24be7c5 100644 --- a/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts +++ b/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts @@ -7,7 +7,6 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { homedir } from "node:os"; import { makeTempHome, runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; -import { removeStateRootVolume } from "../../../../tests/helpers/stack-cleanup.ts"; const nativeSupported = (process.platform === "linux" && (process.arch === "x64" || process.arch === "arm64")) || @@ -79,8 +78,6 @@ describe("functions new (stack e2e)", () => { }); const home = makeTempHome(); yield* Effect.addFinalizer(() => Effect.sync(() => home[Symbol.dispose]())); - if (runtime === "docker") - yield* Effect.addFinalizer(() => removeStateRootVolume(path.join(home.dir, "stacks"))); yield* fs.makeDirectory(path.join(projectDir, "supabase"), { recursive: true }); yield* fs.writeFileString( path.join(projectDir, "supabase", "config.toml"), diff --git a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts index 389c377a8e..5bc41babe2 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts @@ -7,10 +7,7 @@ import { homedir, tmpdir } from "node:os"; import { spawnSupabase } from "../../../../tests/helpers/cli.ts"; import { generateGoJwt } from "../../../command-internal/go-jwt.ts"; -import { - destroyTestStack, - removeStateRootVolume, -} from "../../../../tests/helpers/stack-cleanup.ts"; +import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; const jwtSecret = "functions-serve-stack-e2e-secret-at-least-32-characters"; const nativeSupported = @@ -41,8 +38,6 @@ const fixture = Effect.fn("FunctionsServeE2e.fixture")(function* ( prefix: `functions-serve-${runtime}-`, }); const home = yield* fs.makeTempDirectoryScoped({ prefix: "functions-serve-home-" }); - if (runtime === "docker") - yield* Effect.addFinalizer(() => removeStateRootVolume(path.join(home, "stacks"))); const functionsRoot = path.join(root, "supabase", "functions"); yield* fs.makeDirectory(path.join(functionsRoot, "hello"), { recursive: true }); yield* fs.writeFileString( diff --git a/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts index 0cb004913d..db17d0494a 100644 --- a/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts @@ -28,10 +28,7 @@ import { BundledPostgresClient } from "../../../command-internal/bundled-postgre import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { migrationSquash } from "./squash.handler.ts"; import type { MigrationSquashFlags } from "./squash.command.ts"; -import { - destroyTestStack, - removeStateRootVolume, -} from "../../../../tests/helpers/stack-cleanup.ts"; +import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); @@ -135,13 +132,10 @@ describe("managed migration squash", { timeout: 180_000 }, () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const stateRoot = path.join(root, "stacks"); - if (runtime === "docker") - yield* Effect.addFinalizer(() => removeStateRootVolume(stateRoot)); const api = yield* StackApi; const current = yield* api.create({ projectRoot: root, - stateRoot, + stateRoot: path.join(root, "stacks"), cacheRoot: path.join(root, "cache"), runtime, }); diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index bbd0af32c5..f060421231 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -571,13 +571,21 @@ function applyProcessEnv(values: Readonly>) { return snapshot; } +/** A test run's volume-cleanup id; survives env replacement below unless a caller overrides it. */ +const stackTestRunEnvVar = "SUPABASE_STACK_TEST_RUN"; + export function processEnvLayer( values: Readonly> = {}, ): Layer.Layer { return ConfigProvider.layer( Effect.acquireRelease( Effect.sync(() => { - const snapshot = applyProcessEnv(values); + const ambientTestRun = process.env[stackTestRunEnvVar]; + const snapshot = applyProcessEnv( + stackTestRunEnvVar in values || ambientTestRun === undefined + ? values + : { [stackTestRunEnvVar]: ambientTestRun, ...values }, + ); return { provider: ConfigProvider.fromEnvRecord(process.env, { preserveEmptyStrings: true }), snapshot, diff --git a/apps/cli/tests/helpers/stack-cleanup.ts b/apps/cli/tests/helpers/stack-cleanup.ts index 72733354c3..c5326195a5 100644 --- a/apps/cli/tests/helpers/stack-cleanup.ts +++ b/apps/cli/tests/helpers/stack-cleanup.ts @@ -2,8 +2,6 @@ import type { Stack } from "@supabase/stack/effect"; import { Cause, Effect, Exit } from "effect"; import type { StackApi } from "../../src/command-internal/stack-api.ts"; -export { removeStateRootVolume } from "../../../../packages/stack/tests/docker-fixture.ts"; - /** Destroys a test stack, failing the test when teardown fails. */ export const destroyTestStack = (stack: Stack): Effect.Effect => stack.destroy.pipe(Effect.orDie); diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 8c65253e02..1c20786369 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -68,6 +68,7 @@ export default defineConfig({ // Integration workers start real service processes and containers. maxWorkers: 4, sequence: { groupOrder: 1 }, + globalSetup: ["../../packages/stack/tests/docker-volume-run.ts"], }, }, { @@ -77,6 +78,7 @@ export default defineConfig({ fileParallelism: false, maxWorkers: 1, setupFiles: ["tests/e2e-setup.ts"], + globalSetup: ["../../packages/stack/tests/docker-volume-run.ts"], testTimeout: 120_000, hookTimeout: 120_000, }, diff --git a/packages/stack/ARCHITECTURE.md b/packages/stack/ARCHITECTURE.md index 51017714da..89d6bb79eb 100644 --- a/packages/stack/ARCHITECTURE.md +++ b/packages/stack/ARCHITECTURE.md @@ -676,7 +676,7 @@ await shadow.start(); await shadow.ready(); ``` -The database implementation owns the snapshot format, PostgreSQL data selection, compatibility validation, initialization metadata and credential reconciliation. It uses native filesystem clone/copy operations or container volume/helper operations through the runtime backend. Native entries live below `cacheRoot`; Docker entries share the data volume, in a separate namespace derived from `cacheRoot`. Docker cache reuse requires the same daemon, `stateRoot`, and `cacheRoot`. The cache store retains three entries by last use; saving a key replaces the previous complete entry for that key. Instance-scoped snapshots, which test checkpoints use, live beside the instance's data (native instance root, Docker data namespace or host-backed instance root), are outside cache retention, and are removed when the instance is destroyed; reset keeps them. Cache entries are disposable and do not promise durability across power loss. The orchestrator knows only admission, instance ownership and operation settlement; it never needs to understand PostgreSQL data contents. +The database implementation owns the snapshot format, PostgreSQL data selection, compatibility validation, initialization metadata and credential reconciliation. It uses native filesystem clone/copy operations or container volume/helper operations through the runtime backend. Native entries live below `cacheRoot`; Docker entries share the data volume, in a separate namespace derived from `cacheRoot`. Docker cache reuse requires the same daemon, `stateRoot`, and `cacheRoot`. When the `SUPABASE_STACK_TEST_RUN` environment variable is set, created volumes also carry a `com.supabase.stack-test-run` label so the test run that created them can remove them. The cache store retains three entries by last use; saving a key replaces the previous complete entry for that key. Instance-scoped snapshots, which test checkpoints use, live beside the instance's data (native instance root, Docker data namespace or host-backed instance root), are outside cache retention, and are removed when the instance is destroyed; reset keeps them. Cache entries are disposable and do not promise durability across power loss. The orchestrator knows only admission, instance ownership and operation settlement; it never needs to understand PostgreSQL data contents. Keep the contract narrow: diff --git a/packages/stack/src/effect.integration.test.ts b/packages/stack/src/effect.integration.test.ts index b76727d2e6..723bb24fc1 100644 --- a/packages/stack/src/effect.integration.test.ts +++ b/packages/stack/src/effect.integration.test.ts @@ -30,7 +30,6 @@ import { launchHost } from "./HostProcess.ts"; import * as PromiseApi from "./index.ts"; import * as State from "./State.ts"; import { assertOwnerExited, watchLeaseRelease } from "../tests/owner.ts"; -import { removeStateRootVolume } from "../tests/docker-fixture.ts"; import { foreignRelease } from "../tests/release-owner-fixture.ts"; import { destroyTestStack } from "../tests/stack-cleanup.ts"; import { deriveStackId, resolveStackIdentity } from "./identity/Identity.ts"; @@ -221,11 +220,9 @@ const resetDataStory = (runtime: "native" | "docker") => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: `stack-reset-data-${runtime}-` }); - const stateRoot = `${root}/state`; - if (runtime === "docker") yield* Effect.addFinalizer(() => removeStateRootVolume(stateRoot)); const options = { projectRoot: root, - stateRoot, + stateRoot: `${root}/state`, cacheRoot: `${tmpdir()}/supabase-stack-artifacts`, runtime, } satisfies Parameters[0]; diff --git a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts index 14021aa972..6157f15c5d 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts @@ -1,6 +1,7 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { + ConfigProvider, Crypto, Data, Deferred, @@ -607,6 +608,62 @@ describe("Docker database storage", { timeout: 120_000 }, () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.live("labels its volume with the configured test run", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "docker-storage-test-run-" }); + const storageRoot = path.join(root, "state", "stack", "data"); + const cacheRoot = path.join(root, "cache"); + const instanceRoot = path.join(storageRoot, "database"); + yield* fs.makeDirectory(instanceRoot, { recursive: true }); + const container = yield* makeContainerRuntime({ engine: "docker", root }); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const stackId = `storage-test-run-${yield* crypto.randomUUIDv4}`; + const testRunId = `storage-test-run-${(yield* crypto.randomUUIDv4).slice(0, 8)}`; + const storage = yield* makeDockerDatabaseStorage({ + runtime: "docker", + stackId, + instanceId: "database", + instanceRoot, + root: storageRoot, + cacheRoot, + fs, + path, + crypto, + container, + spawner, + }); + yield* storage + .prepare("17") + .pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnvRecord({ SUPABASE_STACK_TEST_RUN: testRunId }), + ), + ), + ); + const marker = yield* Schema.decodeEffect(Schema.fromJsonString(Marker))( + yield* fs.readFileString(path.join(instanceRoot, ".supabase-database-storage.json")), + ); + if (marker.backend !== "docker" || marker.volume === undefined) + return yield* new DockerTestError({ message: "Docker test selected host fallback" }); + const labels = yield* docker([ + "volume", + "inspect", + "--format", + '{{ index .Labels "com.supabase.stack-test-run" }}', + marker.volume, + ]); + yield* storage.destroyData("17"); + yield* docker(["volume", "rm", marker.volume]); + expect(labels).toBe(testRunId); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("removes an unprepared storage without requiring Docker", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/storage/DockerDatabaseStorage.ts b/packages/stack/src/storage/DockerDatabaseStorage.ts index 5bf4fcec34..12b171b526 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.ts @@ -1,4 +1,5 @@ import { + Config, Crypto, Effect, Exit, @@ -108,8 +109,23 @@ const parseMajor = (version: string): number | undefined => { }; /** Derives the shared Docker volume name from a state-root/daemon identity digest. */ -export const volumeNameFor = (stateDigest: string): string => - `supabase-db-${stateDigest.slice(0, 32)}`; +const volumeNameFor = (stateDigest: string): string => `supabase-db-${stateDigest.slice(0, 32)}`; + +const testRunLabelPattern = /^[A-Za-z0-9-]{1,64}$/u; + +/** Reads the optional test-run id through Effect `Config`, labelling volumes this run creates. */ +const testRunLabelArgs = Effect.fn("DockerDatabaseStorage.testRunLabelArgs")(function* () { + const testRun = yield* Config.option(Config.string("SUPABASE_STACK_TEST_RUN")).pipe( + Effect.mapError((cause) => errorFor("config", cause)), + ); + if (Option.isNone(testRun)) return []; + if (!testRunLabelPattern.test(testRun.value)) + return yield* errorFor( + "config", + `SUPABASE_STACK_TEST_RUN must match ${testRunLabelPattern.source}, got "${testRun.value}"`, + ); + return ["--label", `com.supabase.stack-test-run=${testRun.value}`]; +}); /** Owns the placement and lifecycle of one database's Docker data and snapshot namespaces. */ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make")( @@ -274,15 +290,21 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") Effect.catchTag("DockerDatabaseStorageError", (cause) => !validMarker.initialized && /(?:no such volume|not found)/iu.test(cause.message) - ? engineCommand([ - "volume", - "create", - "--label", - "com.supabase.stack-managed=true", - "--label", - `com.supabase.stack-state-root=${resolved.stateDigest}`, - resolved.volume, - ]).pipe(Effect.asVoid) + ? testRunLabelArgs().pipe( + Effect.flatMap((testRunLabel) => + engineCommand([ + "volume", + "create", + "--label", + "com.supabase.stack-managed=true", + "--label", + `com.supabase.stack-state-root=${resolved.stateDigest}`, + ...testRunLabel, + resolved.volume, + ]), + ), + Effect.asVoid, + ) : Effect.fail(cause), ), ); @@ -320,15 +342,21 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") yield* engineCommand(["volume", "inspect", resolved.volume]).pipe( Effect.catchTag("DockerDatabaseStorageError", (cause) => /no such volume|not found/iu.test(cause.message) - ? engineCommand([ - "volume", - "create", - "--label", - "com.supabase.stack-managed=true", - "--label", - `com.supabase.stack-state-root=${resolved.stateDigest}`, - resolved.volume, - ]).pipe(Effect.asVoid) + ? testRunLabelArgs().pipe( + Effect.flatMap((testRunLabel) => + engineCommand([ + "volume", + "create", + "--label", + "com.supabase.stack-managed=true", + "--label", + `com.supabase.stack-state-root=${resolved.stateDigest}`, + ...testRunLabel, + resolved.volume, + ]), + ), + Effect.asVoid, + ) : Effect.fail(cause), ), Effect.asVoid, diff --git a/packages/stack/tests/docker-fixture.integration.test.ts b/packages/stack/tests/docker-fixture.integration.test.ts index 8ef4c2f579..17303cf6ab 100644 --- a/packages/stack/tests/docker-fixture.integration.test.ts +++ b/packages/stack/tests/docker-fixture.integration.test.ts @@ -54,7 +54,7 @@ const volumeExists = Effect.fn("DockerFixture.volumeExists")((volume: string) => ); describe("Docker database fixture isolation", { timeout: 180_000 }, () => { - it.live("isolates equal identities and removes both owned volumes", () => + it.live("isolates equal identities across separate owned volumes", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -133,7 +133,7 @@ describe("Docker database fixture isolation", { timeout: 180_000 }, () => { ), ), Effect.tap((results) => - Effect.sync(() => results.forEach(({ exists }) => expect(exists).toBe(false))), + Effect.sync(() => results.forEach(({ exists }) => expect(exists).toBe(true))), ), Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), ), diff --git a/packages/stack/tests/docker-fixture.ts b/packages/stack/tests/docker-fixture.ts index 9dcd510178..a900828687 100644 --- a/packages/stack/tests/docker-fixture.ts +++ b/packages/stack/tests/docker-fixture.ts @@ -1,6 +1,5 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { Crypto, Effect, FileSystem, Path, Stream } from "effect"; -import { volumeNameFor } from "../src/storage/DockerDatabaseStorage.ts"; +import { Effect, FileSystem, Stream } from "effect"; import { cleanupDockerRoot } from "./docker-cleanup.ts"; /** Runs a Docker CLI command and returns its combined output and exit code. */ @@ -24,68 +23,22 @@ export const runDocker = Effect.fn("DockerTest.runDocker")((args: ReadonlyArray< ), ); -/** Removes the shared database volume for a state root after verifying its ownership labels. */ -export const removeStateRootVolume = Effect.fn("DockerTest.removeStateRootVolume")( - (stateRoot: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const crypto = yield* Crypto.Crypto; - if (!(yield* fs.exists(stateRoot))) return; - const canonicalStateRoot = yield* fs.realPath(stateRoot); - const daemon = yield* runDocker(["info", "--format", "{{.ID}}"]).pipe( - Effect.flatMap((result) => - result.code === 0 - ? Effect.succeed(result.output.trim()) - : Effect.die(`Docker info failed: ${result.output}`), - ), - ); - const stateDigest = yield* crypto - .digest("SHA-256", new TextEncoder().encode(`${canonicalStateRoot}\0${daemon}`)) - .pipe( - Effect.map((bytes) => - Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""), - ), - ); - const volume = volumeNameFor(stateDigest); - const inspected = yield* runDocker([ - "volume", - "inspect", - "--format", - '{{ index .Labels "com.supabase.stack-managed" }}|{{ index .Labels "com.supabase.stack-state-root" }}', - volume, - ]); - if (inspected.code !== 0) { - if (/no such volume|not found/iu.test(inspected.output)) return; - return yield* Effect.die(`Docker volume inspect failed: ${inspected.output}`); - } - const [managed, labeledState] = inspected.output.trim().split("|"); - if (managed !== "true" || labeledState !== stateDigest) - return yield* Effect.die("Docker fixture volume identity did not match its state root"); - const removed = yield* runDocker(["volume", "rm", volume]); - if (removed.code !== 0 && !/no such volume|not found/iu.test(removed.output)) - return yield* Effect.die(`Docker volume cleanup failed: ${removed.output}`); - }).pipe(Effect.catchCause(Effect.die)), -); - -/** Allocates the documented state-root layout used by Docker database fixtures. */ +/** + * Allocates the documented state-root layout used by Docker database fixtures. The volume this + * state root's Docker database creates is removed by the `SUPABASE_STACK_TEST_RUN` test-run + * cleanup (see `tests/docker-volume-run.ts`), not by this fixture. + */ export const makeDockerDatabaseRoot = Effect.fn("DockerTest.makeDatabaseRoot")( (prefix: string, stackId = "catalog-test") => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const temporaryRoot = yield* fs.makeTempDirectoryScoped({ prefix }); const root = `${temporaryRoot}/state/${stackId}/data`; yield* fs.makeDirectory(root, { recursive: true }); - const stateRoot = path.dirname(path.dirname(root)); yield* Effect.addFinalizer(() => - removeStateRootVolume(stateRoot).pipe( - Effect.andThen( - Effect.gen(function* () { - if (yield* fs.exists(root)) yield* cleanupDockerRoot(root); - }), - ), - Effect.catchCause(Effect.die), - ), + Effect.gen(function* () { + if (yield* fs.exists(root)) yield* cleanupDockerRoot(root); + }).pipe(Effect.catchCause(Effect.die)), ); return root; }), diff --git a/packages/stack/tests/docker-volume-run.integration.test.ts b/packages/stack/tests/docker-volume-run.integration.test.ts new file mode 100644 index 0000000000..b6c9980d3c --- /dev/null +++ b/packages/stack/tests/docker-volume-run.integration.test.ts @@ -0,0 +1,64 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Crypto, Effect } from "effect"; +import { runDocker } from "./docker-fixture.ts"; +import { removeTestRunVolumes } from "./docker-volume-run.ts"; + +const volumeExists = Effect.fn("DockerVolumeRunTest.volumeExists")((name: string) => + runDocker(["volume", "inspect", name]).pipe(Effect.map((result) => result.code === 0)), +); + +describe("test-run Docker volume cleanup", { timeout: 120_000 }, () => { + it.live("removes exactly the volumes labelled for this run", () => + Effect.scoped( + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const suffix = (yield* crypto.randomUUIDv4).slice(0, 8); + const runId = `run-${suffix}`; + const otherRunId = `other-run-${suffix}`; + const owned = `sb-test-run-owned-${suffix}`; + const foreign = `sb-test-run-foreign-${suffix}`; + const unlabelled = `sb-test-run-unlabelled-${suffix}`; + + yield* runDocker([ + "volume", + "create", + "--label", + "com.supabase.stack-managed=true", + "--label", + `com.supabase.stack-test-run=${runId}`, + owned, + ]); + yield* runDocker([ + "volume", + "create", + "--label", + "com.supabase.stack-managed=true", + "--label", + `com.supabase.stack-test-run=${otherRunId}`, + foreign, + ]); + yield* runDocker([ + "volume", + "create", + "--label", + "com.supabase.stack-managed=true", + unlabelled, + ]); + yield* Effect.addFinalizer(() => + Effect.forEach( + [owned, foreign, unlabelled], + (name) => runDocker(["volume", "rm", name]).pipe(Effect.ignore), + { discard: true }, + ), + ); + + yield* removeTestRunVolumes(runId); + + expect(yield* volumeExists(owned)).toBe(false); + expect(yield* volumeExists(foreign)).toBe(true); + expect(yield* volumeExists(unlabelled)).toBe(true); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/stack/tests/docker-volume-run.ts b/packages/stack/tests/docker-volume-run.ts new file mode 100644 index 0000000000..6a49f81b62 --- /dev/null +++ b/packages/stack/tests/docker-volume-run.ts @@ -0,0 +1,85 @@ +import { randomUUID } from "node:crypto"; +import { NodeServices } from "@effect/platform-node"; +import { Effect } from "effect"; +import { runDocker } from "./docker-fixture.ts"; + +/** + * Environment variable `DockerDatabaseStorage` reads to label volumes a test run creates. + * + * Docker test state roots must be private to a run (see `makeDockerDatabaseRoot` and + * `Commands.integration.test.ts`'s Docker variants): a shared root's volume would be labelled + * by whichever run first creates it, and a later run sharing that root could have its data + * removed by this cleanup. The `@supabase/stack/testing` default shared root is outside this + * cleanup and must not be used for Docker-backed tests. + */ +export const stackTestRunEnvVar = "SUPABASE_STACK_TEST_RUN"; + +const managedFilter = "label=com.supabase.stack-managed=true"; +const testRunFilter = (id: string) => `label=com.supabase.stack-test-run=${id}`; + +/** + * Removes exactly the stack-managed Docker volumes labelled with this test run's id. Prints one + * note and does nothing when Docker is missing or its daemon is unreachable. + */ +export const removeTestRunVolumes = Effect.fn("DockerVolumeRun.removeTestRunVolumes")( + (id: string) => + Effect.gen(function* () { + const probe = yield* runDocker(["info", "--format", "{{.ID}}"]).pipe( + Effect.catchCause(() => Effect.succeed({ output: "docker is unavailable", code: 1 })), + ); + if (probe.code !== 0) { + yield* Effect.logWarning( + "[docker-volume-run] Skipping test-run Docker volume cleanup: Docker is missing or its daemon is unreachable.", + ); + return; + } + const listed = yield* runDocker([ + "volume", + "ls", + "-q", + "--filter", + managedFilter, + "--filter", + testRunFilter(id), + ]); + if (listed.code !== 0) + return yield* Effect.die(`docker volume ls failed: ${listed.output.trim()}`); + const volumes = listed.output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (volumes.length === 0) return; + const removals = yield* Effect.forEach(volumes, (volume) => + runDocker(["volume", "rm", volume]).pipe(Effect.map((result) => ({ volume, result }))), + ); + const failed = removals.filter(({ result }) => result.code !== 0); + if (failed.length > 0) + return yield* Effect.die( + `Failed to remove test-run Docker volumes: ${failed + .map(({ volume, result }) => `${volume} (${result.output.trim()})`) + .join(", ")}`, + ); + }), +); + +/** + * Vitest `globalSetup`, wired into the integration and e2e projects of both `packages/stack` and + * `apps/cli`. Generates one random id for this invocation and removes the Docker volumes it + * labels when the run ends, unless an outer invocation already owns `SUPABASE_STACK_TEST_RUN`: + * the root `vitest.config.ts` aggregates every package's projects, so several project setups can + * run in one invocation, and only the outermost one should own cleanup. + */ +// oxlint-disable-next-line effecttsgo/async-function -- Vitest's globalSetup API requires a Promise-returning function; this is the non-Effect consumer boundary. +export async function setup(): Promise<(() => Promise) | undefined> { + // oxlint-disable-next-line effecttsgo/process-env -- must be a real process.env value so vitest workers and spawned CLI subprocesses inherit it. + if (process.env[stackTestRunEnvVar] !== undefined) return undefined; + const id = randomUUID(); + // oxlint-disable-next-line effecttsgo/process-env -- see above. + process.env[stackTestRunEnvVar] = id; + // oxlint-disable-next-line effecttsgo/async-function -- the returned teardown is Vitest's globalSetup contract, not application logic. + return async () => { + await Effect.runPromise(removeTestRunVolumes(id).pipe(Effect.provide(NodeServices.layer))); + }; +} + +export default setup; diff --git a/packages/stack/tests/whole-stack/fixture.ts b/packages/stack/tests/whole-stack/fixture.ts index bce553c5e7..98e048cf87 100644 --- a/packages/stack/tests/whole-stack/fixture.ts +++ b/packages/stack/tests/whole-stack/fixture.ts @@ -19,7 +19,6 @@ import { create, type Stack } from "../../src/effect.ts"; import type { Observation } from "../../src/Rpc.ts"; import { vectorAnalyticsConfig } from "./analytics.ts"; import { cleanupDockerRoot } from "../docker-cleanup.ts"; -import { removeStateRootVolume } from "../docker-fixture.ts"; import { destroyTestStack } from "../stack-cleanup.ts"; type AnyService = Effect.Success[number]; @@ -121,8 +120,6 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => const functionsRoot = `${root}/functions`; const storageRoot = `${root}/storage`; const vectorConfigPath = `${root}/vector.yaml`; - if (runtime === "docker") - yield* Effect.addFinalizer(() => removeStateRootVolume(`${root}/state`)); yield* fs.makeDirectory(`${functionsRoot}/hello`, { recursive: true }); yield* fs.makeDirectory(storageRoot, { recursive: true }); yield* fs.writeFileString( diff --git a/packages/stack/vitest.config.ts b/packages/stack/vitest.config.ts index 88e8c7d186..ca0f72b3de 100644 --- a/packages/stack/vitest.config.ts +++ b/packages/stack/vitest.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ // Integration workers start real service processes and containers. maxWorkers: 4, sequence: { groupOrder: 1 }, + globalSetup: ["tests/docker-volume-run.ts"], }, }, { @@ -34,6 +35,7 @@ export default defineConfig({ name: "e2e", hookTimeout: 120_000, include: ["**/*.e2e.test.ts"], + globalSetup: ["tests/docker-volume-run.ts"], }, }, ], From a8a166d55d084a53a375cf636c001c2cefbffa72 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 1 Oct 2026 11:59:28 +0200 Subject: [PATCH 5/6] test(stack): clean up review findings in test-run volume cleanup The storage labelling test registers its own run cleanup before creating storage, so a failure cannot leak its volume. The selection test checks every Docker result. The run teardown releases the run id it owned. --- .../DockerDatabaseStorage.integration.test.ts | 5 +- .../docker-volume-run.integration.test.ts | 55 ++++++++++--------- packages/stack/tests/docker-volume-run.ts | 7 ++- 3 files changed, 38 insertions(+), 29 deletions(-) diff --git a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts index 6157f15c5d..16619a0b77 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts @@ -24,6 +24,7 @@ import { makeContainerRuntime } from "../runtime/Container.ts"; import { makeDatabaseSnapshots } from "../services/DatabaseSnapshot.ts"; import { makeDockerDatabaseStorage } from "./DockerDatabaseStorage.ts"; import { makeDockerHelperRegistry } from "./DockerHelperRegistry.ts"; +import { removeTestRunVolumes } from "../../tests/docker-volume-run.ts"; import type { DockerHelperRegistry } from "./DockerHelperRegistry.ts"; const postgresImage = (version: string) => @@ -623,6 +624,8 @@ describe("Docker database storage", { timeout: 120_000 }, () => { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const stackId = `storage-test-run-${yield* crypto.randomUUIDv4}`; const testRunId = `storage-test-run-${(yield* crypto.randomUUIDv4).slice(0, 8)}`; + // This run id overrides the ambient one, so the shared run teardown never sees the volume. + yield* Effect.addFinalizer(() => removeTestRunVolumes(testRunId).pipe(Effect.orDie)); const storage = yield* makeDockerDatabaseStorage({ runtime: "docker", stackId, @@ -657,8 +660,6 @@ describe("Docker database storage", { timeout: 120_000 }, () => { '{{ index .Labels "com.supabase.stack-test-run" }}', marker.volume, ]); - yield* storage.destroyData("17"); - yield* docker(["volume", "rm", marker.volume]); expect(labels).toBe(testRunId); }), ).pipe(Effect.provide(NodeServices.layer)), diff --git a/packages/stack/tests/docker-volume-run.integration.test.ts b/packages/stack/tests/docker-volume-run.integration.test.ts index b6c9980d3c..4ad151ce40 100644 --- a/packages/stack/tests/docker-volume-run.integration.test.ts +++ b/packages/stack/tests/docker-volume-run.integration.test.ts @@ -8,6 +8,28 @@ const volumeExists = Effect.fn("DockerVolumeRunTest.volumeExists")((name: string runDocker(["volume", "inspect", name]).pipe(Effect.map((result) => result.code === 0)), ); +const createVolume = Effect.fn("DockerVolumeRunTest.createVolume")( + (name: string, labels: ReadonlyArray) => + runDocker(["volume", "create", ...labels.flatMap((label) => ["--label", label]), name]).pipe( + Effect.flatMap((result) => + result.code === 0 + ? Effect.void + : Effect.die(`docker volume create ${name} failed: ${result.output}`), + ), + ), +); + +const removeVolume = Effect.fn("DockerVolumeRunTest.removeVolume")((name: string) => + runDocker(["volume", "rm", name]).pipe( + Effect.flatMap((result) => + result.code === 0 || /no such volume/iu.test(result.output) + ? Effect.void + : Effect.die(`docker volume rm ${name} failed: ${result.output}`), + ), + Effect.orDie, + ), +); + describe("test-run Docker volume cleanup", { timeout: 120_000 }, () => { it.live("removes exactly the volumes labelled for this run", () => Effect.scoped( @@ -20,38 +42,19 @@ describe("test-run Docker volume cleanup", { timeout: 120_000 }, () => { const foreign = `sb-test-run-foreign-${suffix}`; const unlabelled = `sb-test-run-unlabelled-${suffix}`; - yield* runDocker([ - "volume", - "create", - "--label", + yield* Effect.addFinalizer(() => + Effect.forEach([owned, foreign, unlabelled], removeVolume, { discard: true }), + ); + yield* createVolume(owned, [ "com.supabase.stack-managed=true", - "--label", `com.supabase.stack-test-run=${runId}`, - owned, ]); - yield* runDocker([ - "volume", - "create", - "--label", + yield* createVolume(foreign, [ "com.supabase.stack-managed=true", - "--label", `com.supabase.stack-test-run=${otherRunId}`, - foreign, - ]); - yield* runDocker([ - "volume", - "create", - "--label", - "com.supabase.stack-managed=true", - unlabelled, ]); - yield* Effect.addFinalizer(() => - Effect.forEach( - [owned, foreign, unlabelled], - (name) => runDocker(["volume", "rm", name]).pipe(Effect.ignore), - { discard: true }, - ), - ); + yield* createVolume(unlabelled, ["com.supabase.stack-managed=true"]); + expect(yield* volumeExists(owned)).toBe(true); yield* removeTestRunVolumes(runId); diff --git a/packages/stack/tests/docker-volume-run.ts b/packages/stack/tests/docker-volume-run.ts index 6a49f81b62..e7eec16efe 100644 --- a/packages/stack/tests/docker-volume-run.ts +++ b/packages/stack/tests/docker-volume-run.ts @@ -78,7 +78,12 @@ export async function setup(): Promise<(() => Promise) | undefined> { process.env[stackTestRunEnvVar] = id; // oxlint-disable-next-line effecttsgo/async-function -- the returned teardown is Vitest's globalSetup contract, not application logic. return async () => { - await Effect.runPromise(removeTestRunVolumes(id).pipe(Effect.provide(NodeServices.layer))); + try { + await Effect.runPromise(removeTestRunVolumes(id).pipe(Effect.provide(NodeServices.layer))); + } finally { + // oxlint-disable-next-line effecttsgo/process-env -- releases ownership so a later setup in this process owns its own run. + if (process.env[stackTestRunEnvVar] === id) delete process.env[stackTestRunEnvVar]; + } }; } From ce80c90c13f26f201542becd68a52ff363e6a7fb Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 1 Oct 2026 15:35:03 +0200 Subject: [PATCH 6/6] test(stack): recover Docker resources of interrupted test runs Under Bun, vitest exits on SIGINT and SIGTERM without running globalSetup teardown, so an interrupted run leaked its labelled volumes and left its stack containers running. The owning setup now records a per-user, host-local marker with its pid, and teardown deletes it. Each new setup finds markers whose process is dead and removes that run's labelled containers, then its labelled volumes, then the marker. Live runs and unlabelled resources are never touched. Stack-created containers carry the same run label as volumes, and the env name and label key live in one shared module. --- apps/cli/tests/helpers/mocks.ts | 4 +- packages/stack/ARCHITECTURE.md | 2 +- packages/stack/package.json | 3 +- packages/stack/src/internal/test-run-label.ts | 28 +++ .../src/runtime/Container.integration.test.ts | 29 +++ packages/stack/src/runtime/Container.ts | 54 +++-- .../src/storage/DockerDatabaseStorage.ts | 21 +- .../docker-volume-run.integration.test.ts | 139 +++++++++++- packages/stack/tests/docker-volume-run.ts | 203 ++++++++++++++++-- 9 files changed, 427 insertions(+), 56 deletions(-) create mode 100644 packages/stack/src/internal/test-run-label.ts diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index f060421231..3ce70da4f2 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -4,6 +4,7 @@ import process from "node:process"; import { BunServices } from "@effect/platform-bun"; import { Console, ConfigProvider, Deferred, Effect, Layer, Option, Stream } from "effect"; import type { CliProjectEnvironment, CliProjectPaths } from "@supabase/config"; +import { testRunEnvVar as stackTestRunEnvVar } from "@supabase/stack/internal/test-run-label"; import { cliSettingsLayer } from "../../src/shared/config/cli-settings.layer.ts"; import { CliProjectHome } from "../../src/shared/config/cli-project-home.service.ts"; import { @@ -571,9 +572,6 @@ function applyProcessEnv(values: Readonly>) { return snapshot; } -/** A test run's volume-cleanup id; survives env replacement below unless a caller overrides it. */ -const stackTestRunEnvVar = "SUPABASE_STACK_TEST_RUN"; - export function processEnvLayer( values: Readonly> = {}, ): Layer.Layer { diff --git a/packages/stack/ARCHITECTURE.md b/packages/stack/ARCHITECTURE.md index 89d6bb79eb..b03a8b4c2f 100644 --- a/packages/stack/ARCHITECTURE.md +++ b/packages/stack/ARCHITECTURE.md @@ -676,7 +676,7 @@ await shadow.start(); await shadow.ready(); ``` -The database implementation owns the snapshot format, PostgreSQL data selection, compatibility validation, initialization metadata and credential reconciliation. It uses native filesystem clone/copy operations or container volume/helper operations through the runtime backend. Native entries live below `cacheRoot`; Docker entries share the data volume, in a separate namespace derived from `cacheRoot`. Docker cache reuse requires the same daemon, `stateRoot`, and `cacheRoot`. When the `SUPABASE_STACK_TEST_RUN` environment variable is set, created volumes also carry a `com.supabase.stack-test-run` label so the test run that created them can remove them. The cache store retains three entries by last use; saving a key replaces the previous complete entry for that key. Instance-scoped snapshots, which test checkpoints use, live beside the instance's data (native instance root, Docker data namespace or host-backed instance root), are outside cache retention, and are removed when the instance is destroyed; reset keeps them. Cache entries are disposable and do not promise durability across power loss. The orchestrator knows only admission, instance ownership and operation settlement; it never needs to understand PostgreSQL data contents. +The database implementation owns the snapshot format, PostgreSQL data selection, compatibility validation, initialization metadata and credential reconciliation. It uses native filesystem clone/copy operations or container volume/helper operations through the runtime backend. Native entries live below `cacheRoot`; Docker entries share the data volume, in a separate namespace derived from `cacheRoot`. Docker cache reuse requires the same daemon, `stateRoot`, and `cacheRoot`. When the `SUPABASE_STACK_TEST_RUN` environment variable is set, created volumes and containers also carry a `com.supabase.stack-test-run` label so the test run that created them can remove them. The cache store retains three entries by last use; saving a key replaces the previous complete entry for that key. Instance-scoped snapshots, which test checkpoints use, live beside the instance's data (native instance root, Docker data namespace or host-backed instance root), are outside cache retention, and are removed when the instance is destroyed; reset keeps them. Cache entries are disposable and do not promise durability across power loss. The orchestrator knows only admission, instance ownership and operation settlement; it never needs to understand PostgreSQL data contents. Keep the contract narrow: diff --git a/packages/stack/package.json b/packages/stack/package.json index c93c0764a7..e71911609f 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -11,7 +11,8 @@ "./commands": "./src/Commands.ts", "./internal/dispatch": "./src/internal/dispatch.ts", "./internal/artifacts": "./src/internal/artifacts.ts", - "./internal/release": "./src/internal/release.ts" + "./internal/release": "./src/internal/release.ts", + "./internal/test-run-label": "./src/internal/test-run-label.ts" }, "scripts": { "generate": "bun run scripts/generate-functions-bootstrap.ts", diff --git a/packages/stack/src/internal/test-run-label.ts b/packages/stack/src/internal/test-run-label.ts new file mode 100644 index 0000000000..8cdcdefb24 --- /dev/null +++ b/packages/stack/src/internal/test-run-label.ts @@ -0,0 +1,28 @@ +import { Config, Effect, Option } from "effect"; + +/** Environment variable a test run sets so stack-created Docker/Podman resources can be labelled. */ +export const testRunEnvVar = "SUPABASE_STACK_TEST_RUN"; + +/** Label key stamped on volumes and containers a test run creates. */ +export const testRunLabelKey = "com.supabase.stack-test-run"; + +const testRunLabelPattern = /^[A-Za-z0-9-]{1,64}$/u; + +/** Reads and validates the optional test-run id through Effect `Config`. */ +export const readTestRunId: Effect.Effect, string> = Effect.gen(function* () { + const testRun = yield* Config.option(Config.string(testRunEnvVar)).pipe( + Effect.mapError((cause) => String(cause)), + ); + if (Option.isNone(testRun)) return testRun; + if (!testRunLabelPattern.test(testRun.value)) + return yield* Effect.fail( + `${testRunEnvVar} must match ${testRunLabelPattern.source}, got "${testRun.value}"`, + ); + return testRun; +}); + +/** `--label` CLI args for the configured test-run id, empty when the env var is unset. */ +export const testRunLabelArgs: Effect.Effect, string> = Effect.map( + readTestRunId, + (testRun) => (Option.isNone(testRun) ? [] : ["--label", `${testRunLabelKey}=${testRun.value}`]), +); diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index b724055684..9028e812db 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -2,6 +2,7 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, + ConfigProvider, Crypto, Data, Deferred, @@ -572,6 +573,34 @@ describe("container process adapter", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.live("labels a created container with the configured test run", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); + yield* runtime.prepare(image); + const crypto = yield* Crypto.Crypto; + const testRunId = `container-test-run-${(yield* crypto.randomUUIDv4).slice(0, 8)}`; + const process = yield* runtime + .launch({ + image, + stackId: "container-test-run", + instanceId: "labels-test-run", + env: {}, + args: ["-e", stoppableIdleScript], + }) + .pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnvRecord({ SUPABASE_STACK_TEST_RUN: testRunId }), + ), + ), + ); + const labels = yield* inspectLabels(process.id); + expect(labels["com.supabase.stack-test-run"]).toBe(testRunId); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live( "publishes two private ports and keeps the second service alive after the first stops", () => diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index 43459fbdf9..29d49865f2 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -19,6 +19,7 @@ import { Stream, } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { testRunLabelArgs as readTestRunLabelArgs } from "../internal/test-run-label.ts"; import { identifyContainer } from "./ContainerName.ts"; export class ContainerError extends Data.TaggedError("ContainerError")<{ @@ -93,6 +94,11 @@ const errorFor = (operation: string, cause: unknown) => cause, }); +/** Labels containers this run creates, when `SUPABASE_STACK_TEST_RUN` is set. */ +const testRunLabelArgs = readTestRunLabelArgs.pipe( + Effect.mapError((cause) => errorFor("config", cause)), +); + const rateLimited = (error: ContainerError) => /toomanyrequests|too many requests|rate limit|rate exceeded/iu.test(error.message); @@ -389,26 +395,32 @@ export const makeContainerRuntime = (options: { const hostGateway = options.hostGateway ?? (yield* makeHostGateway); /** Reads `/etc/hosts` from a throwaway container of an already present image. */ const readProbeHosts = (image: string, spec: ContainerSpec, addHost: ReadonlyArray) => - run( - [ - "run", - "--rm", - "--pull", - "never", - ...addHost, - // No instance label: `--rm` removal is asynchronous and must not count as an - // instance container; the stack labels keep it sweepable. - "--label", - `com.supabase.stack=${spec.stackId}`, - "--label", - `com.supabase.stack-root=${stackRoot}`, - "--entrypoint", - "cat", - image, - "/etc/hosts", - ], - { timeout: undefined }, - ).pipe(Effect.timeout(HOST_GATEWAY_PROBE_TIMEOUT)); + testRunLabelArgs.pipe( + Effect.flatMap((testRunLabel) => + run( + [ + "run", + "--rm", + "--pull", + "never", + ...addHost, + // No instance label: `--rm` removal is asynchronous and must not count as an + // instance container; the stack labels keep it sweepable. + "--label", + `com.supabase.stack=${spec.stackId}`, + "--label", + `com.supabase.stack-root=${stackRoot}`, + ...testRunLabel, + "--entrypoint", + "cat", + image, + "/etc/hosts", + ], + { timeout: undefined }, + ), + ), + Effect.timeout(HOST_GATEWAY_PROBE_TIMEOUT), + ); /** Resolves the IPv4 host address the engine writes itself, since it rejects `host-gateway`. */ const engineHostProbe = (image: string, spec: ContainerSpec, rejection: ContainerError) => readProbeHosts(image, spec, []).pipe( @@ -489,6 +501,7 @@ export const makeContainerRuntime = (options: { const { name, composeProject, composeService } = identifyContainer(spec, token, oneOff); const hostAlias = options.engine === "docker" ? yield* hostAliasTarget(image, spec) : undefined; + const testRunLabel = yield* testRunLabelArgs; const createArgs = (target: string | undefined) => [ "create", "--pull", @@ -512,6 +525,7 @@ export const makeContainerRuntime = (options: { "--label", `com.supabase.stack-root=${stackRoot}`, ...(spec.service === undefined ? [] : ["--label", `com.supabase.service=${spec.service}`]), + ...testRunLabel, "--label", `com.docker.compose.project=${composeProject}`, "--label", diff --git a/packages/stack/src/storage/DockerDatabaseStorage.ts b/packages/stack/src/storage/DockerDatabaseStorage.ts index 12b171b526..2ed2658186 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.ts @@ -1,5 +1,4 @@ import { - Config, Crypto, Effect, Exit, @@ -16,6 +15,7 @@ import { ChildProcess } from "effect/unstable/process"; import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/unstable/process/ChildProcessSpawner"; import { postgresVersion, resolveArtifact } from "../Artifacts.ts"; import { failureMessage } from "../internal/failure-message.ts"; +import { testRunLabelArgs as readTestRunLabelArgs } from "../internal/test-run-label.ts"; import type { ContainerRuntime } from "../runtime/Container.ts"; import { composeProjectFor } from "../runtime/ContainerName.ts"; import type { DatabaseRuntime } from "../services/Database.ts"; @@ -111,20 +111,9 @@ const parseMajor = (version: string): number | undefined => { /** Derives the shared Docker volume name from a state-root/daemon identity digest. */ const volumeNameFor = (stateDigest: string): string => `supabase-db-${stateDigest.slice(0, 32)}`; -const testRunLabelPattern = /^[A-Za-z0-9-]{1,64}$/u; - -/** Reads the optional test-run id through Effect `Config`, labelling volumes this run creates. */ +/** Labels volumes and containers this run creates, when `SUPABASE_STACK_TEST_RUN` is set. */ const testRunLabelArgs = Effect.fn("DockerDatabaseStorage.testRunLabelArgs")(function* () { - const testRun = yield* Config.option(Config.string("SUPABASE_STACK_TEST_RUN")).pipe( - Effect.mapError((cause) => errorFor("config", cause)), - ); - if (Option.isNone(testRun)) return []; - if (!testRunLabelPattern.test(testRun.value)) - return yield* errorFor( - "config", - `SUPABASE_STACK_TEST_RUN must match ${testRunLabelPattern.source}, got "${testRun.value}"`, - ); - return ["--label", `com.supabase.stack-test-run=${testRun.value}`]; + return yield* readTestRunLabelArgs.pipe(Effect.mapError((cause) => errorFor("config", cause))); }); /** Owns the placement and lifecycle of one database's Docker data and snapshot namespaces. */ @@ -607,6 +596,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") Effect.mapError((cause) => errorFor("helper", cause)), ); const name = `supabase-db-helper-${token}`; + const testRunLabel = yield* testRunLabelArgs(); // Register the deterministic owned name before the remote create starts so an // interrupted docker run can still be removed by the same scope. yield* Ref.set(helperId, name); @@ -625,6 +615,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") "--label", `com.supabase.stack-root=${options.path.resolve(options.root)}`, ...composeHelperLabels, + ...testRunLabel, ...mountArgs(mounts), preparedImage, "/bin/sh", @@ -700,6 +691,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") const preparedImage = yield* options.container .prepareImage(image) .pipe(Effect.mapError((cause) => errorFor("helper", cause))); + const testRunLabel = yield* testRunLabelArgs(); return yield* Effect.uninterruptible( engineCommand([ "run", @@ -715,6 +707,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") "--label", `com.supabase.stack-root=${options.path.resolve(options.root)}`, ...composeHelperLabels, + ...testRunLabel, ...mountArgs(mounts), preparedImage, "/bin/sh", diff --git a/packages/stack/tests/docker-volume-run.integration.test.ts b/packages/stack/tests/docker-volume-run.integration.test.ts index 4ad151ce40..6233923a8f 100644 --- a/packages/stack/tests/docker-volume-run.integration.test.ts +++ b/packages/stack/tests/docker-volume-run.integration.test.ts @@ -1,8 +1,15 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Crypto, Effect } from "effect"; +import { Crypto, DateTime, Effect, FileSystem, Path } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { runDocker } from "./docker-fixture.ts"; -import { removeTestRunVolumes } from "./docker-volume-run.ts"; +import { + encodeRunMarker, + markerDirectory, + markerFile, + recoverDeadRuns, + removeTestRunVolumes, +} from "./docker-volume-run.ts"; const volumeExists = Effect.fn("DockerVolumeRunTest.volumeExists")((name: string) => runDocker(["volume", "inspect", name]).pipe(Effect.map((result) => result.code === 0)), @@ -30,6 +37,39 @@ const removeVolume = Effect.fn("DockerVolumeRunTest.removeVolume")((name: string ), ); +const containerExists = Effect.fn("DockerVolumeRunTest.containerExists")((name: string) => + runDocker(["inspect", name]).pipe(Effect.map((result) => result.code === 0)), +); + +const createContainer = Effect.fn("DockerVolumeRunTest.createContainer")( + (name: string, labels: ReadonlyArray) => + runDocker([ + "create", + "--name", + name, + ...labels.flatMap((label) => ["--label", label]), + "busybox:1.36", + "true", + ]).pipe( + Effect.flatMap((result) => + result.code === 0 + ? Effect.void + : Effect.die(`docker create ${name} failed: ${result.output}`), + ), + ), +); + +const removeContainer = Effect.fn("DockerVolumeRunTest.removeContainer")((name: string) => + runDocker(["rm", "--force", "--volumes", name]).pipe( + Effect.flatMap((result) => + result.code === 0 || /no such container/iu.test(result.output) + ? Effect.void + : Effect.die(`docker rm ${name} failed: ${result.output}`), + ), + Effect.orDie, + ), +); + describe("test-run Docker volume cleanup", { timeout: 120_000 }, () => { it.live("removes exactly the volumes labelled for this run", () => Effect.scoped( @@ -65,3 +105,98 @@ describe("test-run Docker volume cleanup", { timeout: 120_000 }, () => { ).pipe(Effect.provide(NodeServices.layer)), ); }); + +describe("dead test-run recovery", { timeout: 120_000 }, () => { + it.live( + "removes a dead run's labelled container and volume, leaving a live run's and an unlabelled one", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const suffix = (yield* crypto.randomUUIDv4).slice(0, 8); + const deadRunId = `recovery-dead-${suffix}`; + const liveRunId = `recovery-live-${suffix}`; + const deadVolume = `sb-recovery-dead-volume-${suffix}`; + const liveVolume = `sb-recovery-live-volume-${suffix}`; + const unlabelledVolume = `sb-recovery-unlabelled-volume-${suffix}`; + const deadContainer = `sb-recovery-dead-container-${suffix}`; + const liveContainer = `sb-recovery-live-container-${suffix}`; + const unlabelledContainer = `sb-recovery-unlabelled-container-${suffix}`; + const deadMarker = markerFile(path, deadRunId); + const liveMarker = markerFile(path, liveRunId); + + // Guaranteed dead once awaited: a process that has already exited cannot be a live pid. + const dying = yield* spawner.spawn( + ChildProcess.make(process.execPath, ["-e", "process.exit(0)"], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ); + const deadPid = dying.pid; + yield* dying.exitCode; + + yield* Effect.addFinalizer(() => + Effect.forEach([deadVolume, liveVolume, unlabelledVolume], removeVolume, { + discard: true, + }).pipe( + Effect.andThen( + Effect.forEach( + [deadContainer, liveContainer, unlabelledContainer], + removeContainer, + { discard: true }, + ), + ), + Effect.andThen( + Effect.forEach( + [deadMarker, liveMarker], + (file) => fs.remove(file, { force: true }).pipe(Effect.orDie), + { discard: true }, + ), + ), + ), + ); + + yield* createVolume(deadVolume, [ + "com.supabase.stack-managed=true", + `com.supabase.stack-test-run=${deadRunId}`, + ]); + yield* createVolume(liveVolume, [ + "com.supabase.stack-managed=true", + `com.supabase.stack-test-run=${liveRunId}`, + ]); + yield* createVolume(unlabelledVolume, ["com.supabase.stack-managed=true"]); + yield* createContainer(deadContainer, [`com.supabase.stack-test-run=${deadRunId}`]); + yield* createContainer(liveContainer, [`com.supabase.stack-test-run=${liveRunId}`]); + yield* createContainer(unlabelledContainer, []); + + const startedAt = DateTime.formatIso(yield* DateTime.now); + yield* fs.makeDirectory(markerDirectory(path), { recursive: true }); + yield* fs.writeFileString( + deadMarker, + yield* encodeRunMarker({ pid: deadPid, startedAt }), + ); + yield* fs.writeFileString( + liveMarker, + yield* encodeRunMarker({ pid: process.pid, startedAt }), + ); + + yield* recoverDeadRuns(); + + expect(yield* volumeExists(deadVolume)).toBe(false); + expect(yield* containerExists(deadContainer)).toBe(false); + expect(yield* fs.exists(deadMarker)).toBe(false); + + expect(yield* volumeExists(liveVolume)).toBe(true); + expect(yield* containerExists(liveContainer)).toBe(true); + expect(yield* fs.exists(liveMarker)).toBe(true); + + expect(yield* volumeExists(unlabelledVolume)).toBe(true); + expect(yield* containerExists(unlabelledContainer)).toBe(true); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/stack/tests/docker-volume-run.ts b/packages/stack/tests/docker-volume-run.ts index e7eec16efe..14d469ae63 100644 --- a/packages/stack/tests/docker-volume-run.ts +++ b/packages/stack/tests/docker-volume-run.ts @@ -1,21 +1,53 @@ import { randomUUID } from "node:crypto"; +import { tmpdir, userInfo } from "node:os"; import { NodeServices } from "@effect/platform-node"; -import { Effect } from "effect"; +import { DateTime, Effect, FileSystem, Option, Path, Schema } from "effect"; +import { + testRunEnvVar as stackTestRunEnvVar, + testRunLabelKey, +} from "../src/internal/test-run-label.ts"; import { runDocker } from "./docker-fixture.ts"; /** - * Environment variable `DockerDatabaseStorage` reads to label volumes a test run creates. - * * Docker test state roots must be private to a run (see `makeDockerDatabaseRoot` and * `Commands.integration.test.ts`'s Docker variants): a shared root's volume would be labelled * by whichever run first creates it, and a later run sharing that root could have its data * removed by this cleanup. The `@supabase/stack/testing` default shared root is outside this * cleanup and must not be used for Docker-backed tests. */ -export const stackTestRunEnvVar = "SUPABASE_STACK_TEST_RUN"; +export { stackTestRunEnvVar }; const managedFilter = "label=com.supabase.stack-managed=true"; -const testRunFilter = (id: string) => `label=com.supabase.stack-test-run=${id}`; +const testRunFilter = (id: string) => `label=${testRunLabelKey}=${id}`; + +/** One owned test run's marker: the host process that must still be alive for the run to own it. */ +const RunMarker = Schema.Struct({ pid: Schema.Int, startedAt: Schema.String }); +export type RunMarker = Schema.Schema.Type; +export const encodeRunMarker = Schema.encodeEffect(Schema.fromJsonString(RunMarker)); +const decodeRunMarker = Schema.decodeEffect(Schema.fromJsonString(RunMarker)); + +/** Per-user, host-local directory holding one marker file per test run this host has started. */ +export const markerDirectory = (path: Path.Path): string => + path.join(tmpdir(), `supabase-stack-test-runs-${userInfo().uid}`); + +/** The marker file path for one test run's id, exported so recovery tests can seed fixtures. */ +export const markerFile = (path: Path.Path, id: string): string => + path.join(markerDirectory(path), `${id}.json`); + +/** True when `pid` is a dead process; an unauthorized signal still proves it is alive. */ +const isDead = (pid: number): boolean => { + try { + process.kill(pid, 0); + return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ESRCH"; + } +}; + +const dockerUnavailable = runDocker(["info", "--format", "{{.ID}}"]).pipe( + Effect.map((result) => result.code !== 0), + Effect.catchCause(() => Effect.succeed(true)), +); /** * Removes exactly the stack-managed Docker volumes labelled with this test run's id. Prints one @@ -24,10 +56,7 @@ const testRunFilter = (id: string) => `label=com.supabase.stack-test-run=${id}`; export const removeTestRunVolumes = Effect.fn("DockerVolumeRun.removeTestRunVolumes")( (id: string) => Effect.gen(function* () { - const probe = yield* runDocker(["info", "--format", "{{.ID}}"]).pipe( - Effect.catchCause(() => Effect.succeed({ output: "docker is unavailable", code: 1 })), - ); - if (probe.code !== 0) { + if (yield* dockerUnavailable) { yield* Effect.logWarning( "[docker-volume-run] Skipping test-run Docker volume cleanup: Docker is missing or its daemon is unreachable.", ); @@ -62,24 +91,168 @@ export const removeTestRunVolumes = Effect.fn("DockerVolumeRun.removeTestRunVolu }), ); +// A benign "not found" tolerates a concurrent setup recovering the same dead run first. +const benign = (output: string) => /no such (?:container|volume)/iu.test(output); + +/** Removes a dead run's labelled containers; `Option.none` on success, a message otherwise. */ +const removeDeadRunContainers = Effect.fn("DockerVolumeRun.removeDeadRunContainers")((id: string) => + Effect.gen(function* () { + const listed = yield* runDocker([ + "ps", + "--all", + "--quiet", + "--no-trunc", + "--filter", + testRunFilter(id), + ]); + if (listed.code !== 0) return Option.some(`docker ps failed: ${listed.output.trim()}`); + const ids = listed.output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (ids.length === 0) return Option.none(); + const removed = yield* runDocker(["rm", "--force", "--volumes", ...ids]); + return removed.code === 0 || benign(removed.output) + ? Option.none() + : Option.some(`docker rm failed: ${removed.output.trim()}`); + }), +); + +/** Removes a dead run's labelled, stack-managed volumes; `Option.none` on success. */ +const removeDeadRunVolumes = Effect.fn("DockerVolumeRun.removeDeadRunVolumes")((id: string) => + Effect.gen(function* () { + const listed = yield* runDocker([ + "volume", + "ls", + "-q", + "--filter", + managedFilter, + "--filter", + testRunFilter(id), + ]); + if (listed.code !== 0) return Option.some(`docker volume ls failed: ${listed.output.trim()}`); + const volumes = listed.output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (volumes.length === 0) return Option.none(); + const removals = yield* Effect.forEach(volumes, (volume) => + runDocker(["volume", "rm", volume]).pipe(Effect.map((result) => ({ volume, result }))), + ); + const failed = removals.filter(({ result }) => result.code !== 0 && !benign(result.output)); + return failed.length === 0 + ? Option.none() + : Option.some( + `docker volume rm failed: ${failed + .map(({ volume, result }) => `${volume} (${result.output.trim()})`) + .join(", ")}`, + ); + }), +); + +/** + * Removes one dead run's labelled containers then volumes, deleting its marker only when both + * succeed or find nothing. A failure keeps the marker for the next recovery and logs one warning; + * it never fails the run that triggered recovery. + */ +const recoverDeadRun = Effect.fn("DockerVolumeRun.recoverDeadRun")( + (path: Path.Path, fs: FileSystem.FileSystem, id: string) => + Effect.gen(function* () { + const containerFailure = yield* removeDeadRunContainers(id); + const volumeFailure = yield* removeDeadRunVolumes(id); + const failures = [containerFailure, volumeFailure].filter(Option.isSome).map((o) => o.value); + if (failures.length > 0) { + yield* Effect.logWarning( + `[docker-volume-run] Leaving dead test run ${id}'s marker after a failed recovery: ${failures.join("; ")}`, + ); + return; + } + yield* fs.remove(markerFile(path, id), { force: true }); + }), +); + +/** + * Recovers Docker resources left by test runs whose process died without running teardown (for + * example a SIGKILL, or Bun's vitest exiting on SIGINT/SIGTERM without running globalSetup + * teardown). Only a run whose marked pid is dead is touched; a live run, including a concurrent + * one, is left alone. Does nothing, keeping every marker, when Docker is unreachable. + */ +export const recoverDeadRuns = Effect.fn("DockerVolumeRun.recoverDeadRuns")(() => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = markerDirectory(path); + yield* fs.makeDirectory(directory, { recursive: true }); + if (yield* dockerUnavailable) { + yield* Effect.logWarning( + "[docker-volume-run] Skipping dead test-run recovery: Docker is missing or its daemon is unreachable.", + ); + return; + } + const entries = yield* fs.readDirectory(directory); + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + const id = entry.slice(0, -".json".length); + const marker = yield* fs + .readFileString(path.join(directory, entry)) + .pipe(Effect.flatMap(decodeRunMarker), Effect.option); + if (Option.isNone(marker) || !isDead(marker.value.pid)) continue; + yield* recoverDeadRun(path, fs, id); + } + }), +); + +/** Writes this run's marker so a later setup can recover its resources if it dies uncleanly. */ +const writeOwnMarker = Effect.fn("DockerVolumeRun.writeOwnMarker")((id: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const marker: RunMarker = { + pid: process.pid, + startedAt: DateTime.formatIso(yield* DateTime.now), + }; + yield* fs.writeFileString(markerFile(path, id), yield* encodeRunMarker(marker), { + mode: 0o600, + }); + }), +); + +const deleteOwnMarker = Effect.fn("DockerVolumeRun.deleteOwnMarker")((id: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.remove(markerFile(path, id), { force: true }); + }), +); + /** * Vitest `globalSetup`, wired into the integration and e2e projects of both `packages/stack` and * `apps/cli`. Generates one random id for this invocation and removes the Docker volumes it - * labels when the run ends, unless an outer invocation already owns `SUPABASE_STACK_TEST_RUN`: - * the root `vitest.config.ts` aggregates every package's projects, so several project setups can - * run in one invocation, and only the outermost one should own cleanup. + * labels when the run ends, unless an outer invocation already owns + * `SUPABASE_STACK_TEST_RUN`: the root `vitest.config.ts` aggregates every package's projects, so + * several project setups can run in one invocation, and only the outermost one should own + * cleanup. The outermost setup also recovers resources left by earlier runs that died without + * running this teardown. */ // oxlint-disable-next-line effecttsgo/async-function -- Vitest's globalSetup API requires a Promise-returning function; this is the non-Effect consumer boundary. export async function setup(): Promise<(() => Promise) | undefined> { - // oxlint-disable-next-line effecttsgo/process-env -- must be a real process.env value so vitest workers and spawned CLI subprocesses inherit it. + // oxlint-disable-next-line effecttsgo/process-env -- see below. if (process.env[stackTestRunEnvVar] !== undefined) return undefined; const id = randomUUID(); - // oxlint-disable-next-line effecttsgo/process-env -- see above. + // oxlint-disable-next-line effecttsgo/process-env -- must be a real process.env value so vitest workers and spawned CLI subprocesses inherit it. process.env[stackTestRunEnvVar] = id; + await Effect.runPromise( + recoverDeadRuns().pipe(Effect.andThen(writeOwnMarker(id)), Effect.provide(NodeServices.layer)), + ); // oxlint-disable-next-line effecttsgo/async-function -- the returned teardown is Vitest's globalSetup contract, not application logic. return async () => { try { - await Effect.runPromise(removeTestRunVolumes(id).pipe(Effect.provide(NodeServices.layer))); + await Effect.runPromise( + removeTestRunVolumes(id).pipe( + Effect.andThen(deleteOwnMarker(id)), + Effect.provide(NodeServices.layer), + ), + ); } finally { // oxlint-disable-next-line effecttsgo/process-env -- releases ownership so a later setup in this process owns its own run. if (process.env[stackTestRunEnvVar] === id) delete process.env[stackTestRunEnvVar];