Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 3 additions & 68 deletions apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -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;
});
Expand Down
8 changes: 7 additions & 1 deletion apps/cli/tests/helpers/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
},
{
Expand All @@ -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,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/stack/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
3 changes: 2 additions & 1 deletion packages/stack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 9 additions & 2 deletions packages/stack/src/Commands.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions packages/stack/src/internal/test-run-label.ts
Original file line number Diff line number Diff line change
@@ -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<Option.Option<string>, 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<ReadonlyArray<string>, string> = Effect.map(
readTestRunId,
(testRun) => (Option.isNone(testRun) ? [] : ["--label", `${testRunLabelKey}=${testRun.value}`]),
);
29 changes: 29 additions & 0 deletions packages/stack/src/runtime/Container.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node";
import { describe, expect, it } from "@effect/vitest";
import {
Cause,
ConfigProvider,
Crypto,
Data,
Deferred,
Expand Down Expand Up @@ -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",
() =>
Expand Down
54 changes: 34 additions & 20 deletions packages/stack/src/runtime/Container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")<{
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<string>) =>
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(
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading
Loading