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..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 @@ -1,11 +1,11 @@ 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"; const COMMAND_TIMEOUT_MS = 8 * 60_000; const TEST_TIMEOUT_MS = COMMAND_TIMEOUT_MS * 4 + 2 * 60_000; @@ -31,38 +31,6 @@ 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, @@ -74,39 +42,7 @@ const composeStack = Effect.fn("DbDiffStackCacheE2e.composeStack")(function* ( 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))), - ); + yield* Effect.addFinalizer(() => destroyTestStack(stack)); const [database] = yield* stack.composition.supabase([ { service: "database", @@ -120,7 +56,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/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index bbd0af32c5..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 { @@ -577,7 +578,12 @@ export function processEnvLayer( 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/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..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`. 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/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/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.integration.test.ts b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts index 14021aa972..16619a0b77 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, @@ -23,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) => @@ -607,6 +609,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)}`; + // 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, + 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, + ]); + 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..2ed2658186 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.ts @@ -15,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"; @@ -108,8 +109,12 @@ 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)}`; + +/** Labels volumes and containers this run creates, when `SUPABASE_STACK_TEST_RUN` is set. */ +const testRunLabelArgs = Effect.fn("DockerDatabaseStorage.testRunLabelArgs")(function* () { + return yield* readTestRunLabelArgs.pipe(Effect.mapError((cause) => errorFor("config", cause))); +}); /** Owns the placement and lifecycle of one database's Docker data and snapshot namespaces. */ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make")( @@ -274,15 +279,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 +331,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, @@ -579,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); @@ -597,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", @@ -672,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", @@ -687,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-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 f986eb7931..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,59 +23,22 @@ export const runDocker = Effect.fn("DockerTest.runDocker")((args: ReadonlyArray< ), ); -/** 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 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))); - 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(`${stateRoot}\0${daemon}`)) - .pipe( - Effect.map((bytes) => - Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""), - ), - ); - const volume = volumeNameFor(stateDigest); 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( - Effect.andThen( - Effect.gen(function* () { - if (yield* fs.exists(root)) yield* cleanupDockerRoot(root); - }), - ), - Effect.catchCause(Effect.die), - ), + 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..6233923a8f --- /dev/null +++ b/packages/stack/tests/docker-volume-run.integration.test.ts @@ -0,0 +1,202 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Crypto, DateTime, Effect, FileSystem, Path } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { runDocker } from "./docker-fixture.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)), +); + +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, + ), +); + +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( + 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* Effect.addFinalizer(() => + Effect.forEach([owned, foreign, unlabelled], removeVolume, { discard: true }), + ); + yield* createVolume(owned, [ + "com.supabase.stack-managed=true", + `com.supabase.stack-test-run=${runId}`, + ]); + yield* createVolume(foreign, [ + "com.supabase.stack-managed=true", + `com.supabase.stack-test-run=${otherRunId}`, + ]); + yield* createVolume(unlabelled, ["com.supabase.stack-managed=true"]); + expect(yield* volumeExists(owned)).toBe(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)), + ); +}); + +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 new file mode 100644 index 0000000000..14d469ae63 --- /dev/null +++ b/packages/stack/tests/docker-volume-run.ts @@ -0,0 +1,263 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir, userInfo } from "node:os"; +import { NodeServices } from "@effect/platform-node"; +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"; + +/** + * 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 { stackTestRunEnvVar }; + +const managedFilter = "label=com.supabase.stack-managed=true"; +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 + * 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* () { + if (yield* dockerUnavailable) { + 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(", ")}`, + ); + }), +); + +// 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. 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 -- see below. + if (process.env[stackTestRunEnvVar] !== undefined) return undefined; + const id = randomUUID(); + // 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.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]; + } + }; +} + +export default setup; 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"], }, }, ],