diff --git a/apps/cli/src/next/commands/branches/switch/switch.handler.ts b/apps/cli/src/next/commands/branches/switch/switch.handler.ts index fbf53b6bc0..d470318330 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.handler.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.handler.ts @@ -145,18 +145,18 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { // TODO: run `supabase pull` against the new branch before restarting the stack // so the local config reflects the branch's migrations and seed state. // `pull` does not exist yet. + const launch = stackCheck.value.launch; const launchConfig = - stackCheck.value.launch === undefined - ? toStartStackConfig([], "auto") + launch === undefined + ? toStartStackConfig([], undefined) : withServiceVersions( toStartStackConfig( - stackCheck.value.launch.excludedServices?.filter( - (service): service is ExcludedStackService => - excludedStackServices.some((candidate) => candidate === service), + launch.excludedServices?.filter((service): service is ExcludedStackService => + excludedStackServices.some((candidate) => candidate === service), ) ?? [], - stackCheck.value.launch.mode, + "mode" in launch ? launch.mode : undefined, ), - stackCheck.value.launch.versions, + launch.versions, ); const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot); @@ -166,7 +166,7 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { projectDir: projectHome.projectRoot, name: stackName, portIntents: managedPortIntents(launchConfig, loadedProjectConfig ?? undefined), - ...(stackCheck.value.launch !== undefined && { launch: stackCheck.value.launch }), + ...(launch !== undefined && { launch }), ...launchConfig, }); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts index 680c67417e..815e406985 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts @@ -55,7 +55,7 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio const serviceVersionContext = yield* resolveServiceVersionContext([], undefined); const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot); const stackConfig = withServiceVersions( - toStartStackConfig([], "auto"), + toStartStackConfig([], undefined), serviceVersionContext.runtimeVersions, ); const stackLayer = yield* daemonLayer({ @@ -65,7 +65,6 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio name: opts.stack, edgeRuntime: opts.edgeRuntime, launch: { - mode: "auto", versions: serviceVersionContext.pinnedBaseline, excludedServices: [], }, diff --git a/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts b/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts index dc23a41bac..2316f1ec8b 100644 --- a/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts +++ b/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts @@ -11,14 +11,14 @@ import { } from "../../config/service-version-resolution.ts"; describe("service version overrides", () => { - test("parses and normalizes repeated flag overrides", async () => { + test("parses repeated flag overrides as exact image tags", async () => { await expect( Effect.runPromise( parseServiceVersionOverrides(["postgrest=v14.5", "mailpit=1.30.2", "auth=2.180.0"]), ), ).resolves.toEqual({ - postgrest: "14.5", - mailpit: "v1.30.2", + postgrest: "v14.5", + mailpit: "1.30.2", auth: "2.180.0", }); }); @@ -27,8 +27,8 @@ describe("service version overrides", () => { const candidateBaseline = { ...DEFAULT_VERSIONS, postgres: "17.6.1.090", - postgrest: "14.5", - auth: "2.187.0", + postgrest: "v14.5", + auth: "v2.187.0", }; const layer = Layer.mergeAll( @@ -68,12 +68,12 @@ describe("service version overrides", () => { runtimeVersions: { ...candidateBaseline, postgres: "17.4.1.045", - auth: "2.170.0", + auth: "v2.170.0", storage: "1.40.0", }, activeOverrides: [ { service: "postgres", version: "17.4.1.045", source: "flag" }, - { service: "auth", version: "2.170.0", source: "flag" }, + { service: "auth", version: "v2.170.0", source: "flag" }, { service: "storage", version: "1.40.0", source: "local" }, ], availableUpdates: [], diff --git a/apps/cli/src/next/commands/start/start.command.ts b/apps/cli/src/next/commands/start/start.command.ts index fbcd3043b6..759faae548 100644 --- a/apps/cli/src/next/commands/start/start.command.ts +++ b/apps/cli/src/next/commands/start/start.command.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Context } from "effect"; +import { Effect, Layer, Context, Option } from "effect"; import { loadProjectConfig } from "@supabase/config"; import { DEFAULT_MANAGED_STACK_NAME, @@ -79,14 +79,15 @@ export const serviceVersionFlag = Flag.string("service-version").pipe( const modeFlag = Flag.choice("mode", startModes).pipe( Flag.withDescription( - 'Stack startup mode. "auto" prefers native binaries and falls back to Docker, "native" requires native-compatible services, and "docker" forces Docker for all services.', + 'Stack startup mode. "native" requires native-compatible services and "docker" requires a usable Docker or Podman runtime.', ), - Flag.withDefault("auto" as StartMode), + Flag.optional, + Flag.map(Option.getOrUndefined), ); interface StartVersionStateShape { readonly launch: { - readonly mode: StartMode; + readonly mode?: StartMode; readonly versions: Readonly>; readonly excludedServices: ReadonlyArray; }; @@ -124,7 +125,7 @@ export type StartFlags = CliCommand.Command.Config.Infer; export const startCommand = Command.make("start", flags).pipe( Command.withDescription( "Start the local Supabase development stack.\n\n" + - "Starts the full local Supabase stack. Use --mode auto (default) to prefer native binaries and fall back to Docker, --mode native to require native-compatible services, or --mode docker to force Docker-backed startup.\n\n" + + "Starts the full local Supabase stack. By default, a usable Docker or Podman runtime selects Docker mode; otherwise the stack uses native mode. Use --mode to require one explicitly.\n\n" + "Named CLI stacks persist managed runtime state under the Supabase home directory. Use --exclude to skip optional services. Use --detach to run in the background.", ), Command.withShortDescription("Start local Supabase stack"), @@ -219,7 +220,7 @@ export const startCommand = Command.make("start", flags).pipe( portDocument: portIntents, }); const launch = { - mode: flags.mode, + ...(flags.mode === undefined ? {} : { mode: flags.mode }), versions: serviceVersionContext.pinnedBaseline, excludedServices: flags.exclude, ...(existingSummary?.lastNotifiedUpdateFingerprint === undefined @@ -242,12 +243,15 @@ export const startCommand = Command.make("start", flags).pipe( cwd: runtimeInfo.cwd, name: flags.stack, }); + if (summary.launch === undefined) { + return yield* Effect.die("Managed stack started without persisted launch settings"); + } return { stackLayer, startVersionState: StartVersionState.of({ launch: { - mode: flags.mode, + mode: "mode" in summary.launch ? summary.launch.mode : undefined, versions: serviceVersionContext.pinnedBaseline, excludedServices: flags.exclude, }, diff --git a/apps/cli/src/next/commands/start/start.handler.ts b/apps/cli/src/next/commands/start/start.handler.ts index deafa672bf..2a388237f7 100644 --- a/apps/cli/src/next/commands/start/start.handler.ts +++ b/apps/cli/src/next/commands/start/start.handler.ts @@ -50,7 +50,6 @@ export const start = Effect.fnUntraced(function* (flags: StartFlags) { yield* updateManagedLaunch({ ...lifecycleInput, launch: { - mode: launch.mode, versions: launch.versions, excludedServices: launch.excludedServices, lastNotifiedUpdateFingerprint: serviceVersionContext.updateFingerprint, @@ -68,7 +67,7 @@ export const start = Effect.fnUntraced(function* (flags: StartFlags) { } yield* analytics.capture("cli_stack_started", { - mode: flags.mode, + mode: launch.mode, detach: flags.detach, stack: flags.stack, }); diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts index e98b31af2a..920bfba263 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -80,7 +80,7 @@ describe("start handler", () => { ); return start({ stack: fixture.stackName, - mode: "auto", + mode: "docker", exclude: [], serviceVersion: [], detach: false, diff --git a/apps/cli/src/next/commands/status/status.handler.ts b/apps/cli/src/next/commands/status/status.handler.ts index 546c89b549..4330121f07 100644 --- a/apps/cli/src/next/commands/status/status.handler.ts +++ b/apps/cli/src/next/commands/status/status.handler.ts @@ -40,12 +40,11 @@ const resolveConfiguredSummary = Effect.fnUntraced(function* (input: { const current = yield* resolveStackSummary(input); const loaded = yield* loadProjectConfig(input.projectDir); const excluded = (current.launch?.excludedServices ?? []).filter(isExcludedStackService); + const mode = + current.launch !== undefined && "mode" in current.launch ? current.launch.mode : undefined; return yield* resolveStackSummary({ ...input, - portDocument: managedPortIntents( - toStartStackConfig(excluded, current.launch?.mode ?? "auto"), - loaded ?? undefined, - ), + portDocument: managedPortIntents(toStartStackConfig(excluded, mode), loaded ?? undefined), }); }); diff --git a/apps/cli/src/next/commands/update/update.handler.ts b/apps/cli/src/next/commands/update/update.handler.ts index c5ad206314..165b4b6739 100644 --- a/apps/cli/src/next/commands/update/update.handler.ts +++ b/apps/cli/src/next/commands/update/update.handler.ts @@ -103,19 +103,14 @@ export const update = Effect.fnUntraced(function* (flags: UpdateFlags) { ); if (Option.isSome(existingSummary)) { - const persistedLaunch = existingSummary.value.launch ?? { - mode: "auto" as const, - excludedServices: [] as const, - }; yield* updateManagedLaunch({ cacheRoot: cliConfig.supabaseHome, cwd: runtimeInfo.cwd, workspacePath: projectHome.projectRoot, stackName: flags.stack, launch: { - mode: persistedLaunch.mode, versions: serviceVersionContext.candidateBaseline, - excludedServices: persistedLaunch.excludedServices, + excludedServices: existingSummary.value.launch?.excludedServices ?? [], ...(existingSummary.value.lastNotifiedUpdateFingerprint === undefined ? {} : { diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 8cc9880552..e68b5873db 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -17,17 +17,16 @@ export const excludedStackServices = [ export type ExcludedStackService = (typeof excludedStackServices)[number]; export const isExcludedStackService = (value: string): value is ExcludedStackService => excludedStackServices.some((candidate) => candidate === value); -export const startModes = ["native", "auto", "docker"] as const; +export const startModes = ["native", "docker"] as const; export type StartMode = (typeof startModes)[number]; export function toStartStackConfig( exclude: ReadonlyArray, - mode: StartMode, + mode?: StartMode, ): StackConfig { const excluded = new Set(exclude); return { - mode, - startupMode: "lazy", + ...(mode === undefined ? {} : { mode }), realtime: excluded.has("realtime") ? false : {}, storage: excluded.has("storage") ? false : {}, imgproxy: excluded.has("imgproxy") || excluded.has("storage") ? false : {}, diff --git a/apps/cli/src/next/config/stack-config.unit.test.ts b/apps/cli/src/next/config/stack-config.unit.test.ts index d60e7d20fa..0ebc50d531 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -2,28 +2,29 @@ import { describe, expect, it } from "vitest"; import { toStartStackConfig, withServiceVersions } from "./stack-config.ts"; describe("toStartStackConfig", () => { - it("uses lazy service startup with the requested runtime mode", () => { - expect(toStartStackConfig([], "auto")).toMatchObject({ - mode: "auto", - startupMode: "lazy", + it("leaves mode unset so the stack package can select the usable runtime", () => { + expect(toStartStackConfig([], undefined)).not.toHaveProperty("mode"); + }); + + it("uses the requested runtime mode and catalog service defaults", () => { + expect(toStartStackConfig([], "docker")).toMatchObject({ + mode: "docker", }); expect(toStartStackConfig([], "docker")).toMatchObject({ mode: "docker", - startupMode: "lazy", }); expect(toStartStackConfig([], "native")).toMatchObject({ mode: "native", - startupMode: "lazy", }); }); it("dedupes excluded services when building stack config", () => { - expect(toStartStackConfig(["auth", "auth"], "auto")).toMatchObject({ - mode: "auto", + expect(toStartStackConfig(["auth", "auth"], "docker")).toMatchObject({ + mode: "docker", auth: false, }); - expect(toStartStackConfig(["auth", "postgrest"], "auto")).toMatchObject({ - mode: "auto", + expect(toStartStackConfig(["auth", "postgrest"], "docker")).toMatchObject({ + mode: "docker", auth: false, postgrest: false, }); @@ -33,7 +34,7 @@ describe("toStartStackConfig", () => { describe("withServiceVersions", () => { it("injects linked service versions without re-enabling excluded services", () => { expect( - withServiceVersions(toStartStackConfig([], "auto"), { + withServiceVersions(toStartStackConfig([], "docker"), { postgres: "17.6.1.090", postgrest: "14.5", auth: "2.187.0", @@ -49,7 +50,7 @@ describe("withServiceVersions", () => { }); expect( - withServiceVersions(toStartStackConfig(["auth", "storage"], "auto"), { + withServiceVersions(toStartStackConfig(["auth", "storage"], "docker"), { postgres: "17.6.1.090", auth: "2.187.0", storage: "1.39.2", diff --git a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts index 4d3a374a95..4da25f32c0 100644 --- a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts +++ b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts @@ -72,6 +72,45 @@ describe("stack e2e cleanup manager", () => { expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); }); + it("keeps cleanup best-effort when the associated home cannot be disposed", async () => { + const calls: Array = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + captureSnapshot: () => ({ + managedStacksRootExists: true, + documentFiles: [], + stackDirs: [], + trackedPids: [], + }), + }), + ); + + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + throw permissionError("home is not removable"); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: async () => { + calls.push("cleanup-project"); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); + + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(manager.drain()).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("/tmp/home")); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("home is not removable")); + } finally { + warn.mockRestore(); + } + expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); + }); + it("canonicalizes symlinked project and home paths before matching stack state", async () => { const root = mkdtempSync(join(tmpdir(), "stack-e2e-cleanup-")); const project = join(root, "project"); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 2e532a5c1f..b8d6ad505c 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -983,6 +983,9 @@ const externalActionabilityByTag: Record = { ? { ...actionability.invalidConfig, fingerprint_suffix: "port_allocation" } : actionability.unknown, BinaryNotFoundError: () => actionability.invalidConfig, + BinaryManifestError: () => actionability.externalNetwork, + BinaryRuntimeError: () => actionability.externalNetwork, + BinaryHostCompatibilityError: () => actionability.invalidConfig, DownloadError: () => actionability.externalNetwork, ChecksumMismatchError: () => ({ ...actionability.externalNetwork, diff --git a/apps/cli/tests/helpers/running-stack.ts b/apps/cli/tests/helpers/running-stack.ts index 0ac4e85d51..712dad8ea8 100644 --- a/apps/cli/tests/helpers/running-stack.ts +++ b/apps/cli/tests/helpers/running-stack.ts @@ -24,7 +24,12 @@ import { CliConfig } from "../../src/next/config/cli-config.service.ts"; import { ProjectHome } from "../../src/next/config/project-home.service.ts"; import { RuntimeInfo } from "../../src/shared/runtime/runtime-info.service.ts"; -const launch = { mode: "auto" as const, versions: { postgres: "17.6.1" }, excludedServices: [] }; +const launch = { + mode: "docker" as const, + containerRuntime: "docker" as const, + versions: { postgres: "17.6.1" }, + excludedServices: [], +}; const portDocument: ManagedPortIntentDocument = { activeFields: ["apiPort", "dbPort"], document: {}, diff --git a/apps/cli/tests/helpers/stack-e2e-cleanup.ts b/apps/cli/tests/helpers/stack-e2e-cleanup.ts index b6815fecc8..7f69b7e9d4 100644 --- a/apps/cli/tests/helpers/stack-e2e-cleanup.ts +++ b/apps/cli/tests/helpers/stack-e2e-cleanup.ts @@ -441,7 +441,11 @@ export function createStackE2eCleanupManager( failures.push(cleanupErrorDetail(project.dir, error)); } finally { if (home !== undefined) { - home.dispose(); + try { + home.dispose(); + } catch (error) { + failures.push(cleanupErrorDetail(home.dir, error)); + } } } } diff --git a/packages/process-compose/tests/helpers/mocks.ts b/packages/process-compose/tests/helpers/mocks.ts index 2df118417b..849c85eef5 100644 --- a/packages/process-compose/tests/helpers/mocks.ts +++ b/packages/process-compose/tests/helpers/mocks.ts @@ -17,10 +17,10 @@ const isOneShotSupervisor = (args: ReadonlyArray): boolean => { typeof config === "object" && config !== null && "command" in config && - config.command === "bash" && "args" in config && Array.isArray(config.args) && - config.args[0] === "-c" + ((config.command === "bash" && config.args[0] === "-c") || + ((config.command === "docker" || config.command === "podman") && config.args[0] === "exec")) ); } catch { return false; diff --git a/packages/stack/README.md b/packages/stack/README.md index b324a54241..4a6186c5b2 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -15,7 +15,9 @@ console.log((await stack.getInfo()).url); `createStack` resolves configuration, reserves ports, and builds a scoped handle. `stack.start()` starts services; disposing the handle stops them and -releases its lease. +releases its lease. When `mode` is omitted, creation uses Docker mode with a +usable Docker or Podman service and otherwise selects native mode. An explicit +mode never falls back to the other one. ## Managed stack @@ -47,7 +49,7 @@ const runtime = projectDir: projectRoot, name: "default", portIntents, - launch: { mode: "auto", versions: {}, excludedServices: [] }, + launch: { mode: "docker", versions: {}, excludedServices: [] }, }); ``` @@ -56,5 +58,10 @@ const runtime = `stopDaemon` and the discovery helpers delegate to the managed lifecycle facade. No CLI metadata file or PID polling is involved. +After a managed supervisor claims a stack, its persisted Docker, Podman, or +native selection remains pinned even if startup later fails. Retry after +restoring or starting that runtime; delete and recreate the stack to choose a +different execution mode. Deletion removes the stack's managed data. + For the end-to-end lifecycle, identity, ports, service execution, transport, compiled-Bun re-entry, and testing boundary, see [How `@supabase/stack` works](docs/architecture.md). diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index d89005bcc9..9f2b4b434d 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -79,8 +79,11 @@ prepares/builds a scoped runtime and handle. It does **not** start service processes. Asset preparation and process-compose graph construction happen when the handle is first started or a service is activated. -`stack.start()` starts services according to the configured startup mode and -waits for the selected readiness policy. The handle also exposes status, logs, +`stack.start()` prepares and starts Postgres plus the services whose resource +policy is `eager`, then waits for the selected readiness policy. Services whose +policy is `lazy` remain dormant until a proxy request or explicit service +operation activates them; activation prepares the service and its required +dependencies before starting it. The handle also exposes status, logs, per-service operations, and graceful `stop()`/`dispose()` methods. Its scope owns service processes and releases the lease when disposed. A direct stack never reads or writes managed documents and never coordinates with a sibling @@ -222,15 +225,27 @@ status, service operations, logs, graceful stop, and launch-update routes; ## Service execution and `ApiProxy` -`StackPreparation` resolves each enabled service to a verified native binary or -a Docker image. `mode: "native"` uses the supported native services and rejects -Docker-only services; `mode: "docker"` resolves every service to an image; -`mode: "auto"` prefers native artifacts and falls back to Docker. The -`StackBuilder` turns those resolutions into one process-compose graph, so a -stack can run native and Docker-backed services together. Docker resources are -namespaced with the managed stack id; when native Postgres is combined with -Docker services, the graph supplies the platform-specific host address so the -containers can reach it. +`StackPreparation` plans resources without materializing them, then prepares +only the requested services and their required dependency closure. Concurrent +requests for the same resource share one installation or pull. Native assets +come from the pinned slim-services release contract and are checksum- and +manifest-verified before atomic publication. Docker assets use one canonical +`ghcr.io/supabase/cli/:` reference; a locally cached image is +reused and a missing image is pulled without registry fallback. Stack creation +selects exactly one execution mode: a usable Docker daemon is preferred, then a +usable Podman service; if neither responds, the stack uses native mode. An +explicit `mode: "native"` rejects Docker-only services, while explicit `mode: +"docker"` requires a usable container runtime. Preparation never falls back to +the other mode after that choice. Once a managed supervisor claims and persists +that selection, it remains pinned even when startup later fails during image +pull, native download, graph build, or readiness. Retries restore or start the +persisted runtime and reuse the same mode; selecting another mode requires +deleting and recreating the stack, which removes its managed data. + +The `StackBuilder` turns planned resolutions into one process-compose graph, +without eagerly materializing every graph resource. Container resources are +namespaced with the managed stack id and every pull, launch, health check, and +cleanup uses the selected Docker or Podman executable. `ApiProxy` listens on the configured public `apiPort` and routes Supabase API paths (`/auth`, `/rest`, `/functions`, `/realtime`, `/storage`, `/pg`, diff --git a/packages/stack/src/BinaryResolver.integration.test.ts b/packages/stack/src/BinaryResolver.integration.test.ts index 5dc58fe38d..f02752dc1c 100644 --- a/packages/stack/src/BinaryResolver.integration.test.ts +++ b/packages/stack/src/BinaryResolver.integration.test.ts @@ -1,210 +1,627 @@ +import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; +import { zstdCompressSync } from "node:zlib"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, + symlinkSync, utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, dirname, join } from "node:path"; -import { NodeServices } from "@effect/platform-node"; +import { dirname, join } from "node:path"; +import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Deferred, Effect, Fiber, FileSystem, Layer } from "effect"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; -import { afterEach } from "vitest"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { BinaryResolver } from "./BinaryResolver.ts"; -import { DownloadError } from "./errors.ts"; +import { + BinaryHostCompatibilityError, + BinaryManifestError, + BinaryRuntimeError, + ChecksumMismatchError, + DownloadError, +} from "./errors.ts"; import { detectPlatform } from "./Platform.ts"; import { nativeReleaseForService } from "./ServiceCatalog.ts"; import { DEFAULT_VERSIONS } from "./versions.ts"; -const tempRoots: string[] = []; +const makeRoot = (): string => mkdtempSync(join(tmpdir(), "stack-slim-services-")); -const makeTempRoot = (): string => { - const root = mkdtempSync(join(tmpdir(), "stack-binary-resolver-")); - tempRoots.push(root); - return root; +const makeFixture = ( + root: string, + manifestOverride: Record = {}, + includePostgrest = true, +) => { + const source = join(root, "source"); + const tar = join(root, "postgrest.tar"); + const archive = join(root, "postgrest.tar.zst"); + rmSync(source, { recursive: true, force: true }); + mkdirSync(join(source, "bin"), { recursive: true }); + if (includePostgrest) { + writeFileSync(join(source, "bin", "postgrest"), "#!/bin/sh\necho postgrest\n"); + } + execFileSync("tar", ["-cf", tar, "-C", source, "."]); + writeFileSync(archive, zstdCompressSync(readFileSync(tar))); + return { archive: readFileSync(archive), manifestOverride }; }; -const makeArchive = (root: string): Uint8Array => { - const source = join(root, "source"); - const archive = join(root, "auth.tar.gz"); - execFileSync("mkdir", ["-p", source]); - writeFileSync(join(source, "auth"), "#!/bin/sh\necho auth\n"); - execFileSync("tar", ["czf", archive, "-C", source, "."]); - return readFileSync(archive); +const writeTarOctal = (header: Buffer, offset: number, length: number, value: number): void => { + const encoded = `${value.toString(8).padStart(length - 1, "0")}\0`; + header.write(encoded, offset, length, "ascii"); +}; + +const makeTarArchive = (member: string, contents: string): Buffer => { + const payload = Buffer.from(contents); + const header = Buffer.alloc(512); + header.write(member, 0, 100, "utf8"); + writeTarOctal(header, 100, 8, 0o644); + writeTarOctal(header, 108, 8, 0); + writeTarOctal(header, 116, 8, 0); + writeTarOctal(header, 124, 12, payload.length); + writeTarOctal(header, 136, 12, 0); + header[156] = "0".charCodeAt(0); + header.write("ustar\0", 257, 6, "ascii"); + header.write("00", 263, 2, "ascii"); + header.fill(0x20, 148, 156); + const checksum = header.reduce((sum, byte) => sum + byte, 0); + header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii"); + const padding = Buffer.alloc((512 - (payload.length % 512)) % 512); + return Buffer.concat([header, payload, padding, Buffer.alloc(1024)]); +}; + +const makeTraversalFixture = (root: string) => { + const archive = join(root, "postgrest-traversal.tar.zst"); + writeFileSync(join(root, "outside.txt"), "must not extract\n"); + writeFileSync(archive, zstdCompressSync(makeTarArchive("../outside.txt", "must not extract\n"))); + return { archive: readFileSync(archive), manifestOverride: {} }; }; -const makeResolverLayer = (cacheRoot: string, archive: Uint8Array, onRequest: () => void) => { +const makeEscapingSymlinkFixture = (root: string) => { + const source = join(root, "symlink-source"); + const tar = join(root, "postgrest-symlink.tar"); + const archive = join(root, "postgrest-symlink.tar.zst"); + rmSync(source, { recursive: true, force: true }); + mkdirSync(join(source, "bin"), { recursive: true }); + symlinkSync("/bin/sh", join(source, "bin/postgrest")); + execFileSync("tar", ["-cf", tar, "-C", source, "."]); + writeFileSync(archive, zstdCompressSync(readFileSync(tar))); + return { archive: readFileSync(archive), manifestOverride: {} }; +}; + +const makeResolverLayer = ( + cacheRoot: string, + fixture: ReturnType, + options: { + readonly checksum?: string; + readonly checksumText?: string; + readonly spawnedCommands?: Array<{ command: string; args: ReadonlyArray }>; + readonly transformFileSystem?: (fileSystem: FileSystem.FileSystem) => FileSystem.FileSystem; + } = {}, +) => { const client = HttpClient.make((request) => Effect.sync(() => { - onRequest(); - return HttpClientResponse.fromWeb(request, new Response(archive, { status: 200 })); + if (request.url.endsWith(".manifest.json")) { + return HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + target: process.platform === "darwin" ? "darwin-arm64" : "linux-amd64", + entrypoint: [], + cmd: ["/bin/postgrest"], + runtime_requires: null, + libc: process.platform === "linux" ? "glibc" : null, + os_floor: + process.platform === "linux" + ? { kind: "glibc", floor: null, scanned: 1 } + : { kind: "macos", floor: null, scanned: 1 }, + ...fixture.manifestOverride, + }), + { status: 200 }, + ), + ); + } + if (request.url.endsWith("SHA256SUMS")) { + const hash = options.checksum ?? createHash("sha256").update(fixture.archive).digest("hex"); + const checksumText = + options.checksumText ?? + `${hash} postgrest-${DEFAULT_VERSIONS.postgrest}-${process.platform === "darwin" ? "darwin-arm64" : "linux-amd64"}.tar.zst\n`; + return HttpClientResponse.fromWeb(request, new Response(checksumText, { status: 200 })); + } + return HttpClientResponse.fromWeb(request, new Response(fixture.archive, { status: 200 })); }), ); + const spawnerLayer = + options.spawnedCommands === undefined + ? NodeServices.layer + : Layer.effect( + ChildProcessSpawner.ChildProcessSpawner, + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + return ChildProcessSpawner.make((command) => { + if (command._tag === "StandardCommand") { + options.spawnedCommands?.push({ + command: command.command, + args: command.args, + }); + } + return delegate.spawn(command); + }); + }), + ).pipe(Layer.provide(NodeServices.layer)); + const fileSystemLayer = + options.transformFileSystem === undefined + ? NodeFileSystem.layer + : Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, options.transformFileSystem), + ).pipe(Layer.provide(NodeFileSystem.layer)); return BinaryResolver.make(cacheRoot).pipe( - Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), - Layer.provide(NodeServices.layer), + Layer.provide( + Layer.mergeAll( + Layer.succeed(HttpClient.HttpClient, client), + spawnerLayer, + fileSystemLayer, + NodePath.layer, + ), + ), ); }; -const makeUnavailableResolverLayer = (cacheRoot: string) => { - const client = HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb(request, new Response("unavailable", { status: 503 })), - ), +describe("BinaryResolver slim-services installer", () => { + it.live("installs a tar.zst archive into an empty cache and reuses it", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root); + const resolverLayer = makeResolverLayer(root, fixture); + const release = nativeReleaseForService( + "postgrest", + DEFAULT_VERSIONS.postgrest, + yield* detectPlatform, + ); + if (release === undefined) return; + const result = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + const first = yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + const second = yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + return { first, second }; + }).pipe(Effect.provide(resolverLayer)); + expect(result.first.downloaded).toBe(true); + expect(result.second.downloaded).toBe(false); + expect(readFileSync(join(result.first.path, "bin/postgrest"), "utf8")).toContain( + "postgrest", + ); + expect(existsSync(join(result.first.path, ".complete"))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), ); - return BinaryResolver.make(cacheRoot).pipe( - Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), - Layer.provide(NodeServices.layer), + + it.live("rejects a complete cache prepared for an incompatible host", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const resolverLayer = makeResolverLayer(root, makeFixture(root)); + const installed = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + + const markerPath = join(installed, ".complete"); + const marker = JSON.parse(readFileSync(markerPath, "utf8")); + marker.hostCompatibility = + process.platform === "darwin" + ? { + runtimeRequires: null, + libc: null, + osFloor: { kind: "macos", floor: "999.0" }, + } + : { + runtimeRequires: "glibc", + libc: "glibc", + osFloor: { kind: "glibc", floor: "999.0" }, + }; + writeFileSync(markerPath, JSON.stringify(marker)); + + const failure = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer), Effect.flip); + + expect(failure).toBeInstanceOf(BinaryHostCompatibilityError); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), ); -}; -const authCachePath = (cacheRoot: string) => - Effect.gen(function* () { - const platform = yield* detectPlatform; - const release = nativeReleaseForService("auth", DEFAULT_VERSIONS.auth, platform); - if (release === undefined) { - return yield* Effect.die(`unsupported test platform: ${platform.os}-${platform.arch}`); - } - return BinaryResolver.cachePath(join(cacheRoot, "bin"), { - service: "auth", - provider: release.provider, - version: DEFAULT_VERSIONS.auth, - assetName: release.assetName, - }); - }); - -const legacyAuthCachePath = (cacheRoot: string) => - Effect.gen(function* () { - const platform = yield* detectPlatform; - const release = nativeReleaseForService("auth", DEFAULT_VERSIONS.auth, platform); - if (release === undefined) { - return yield* Effect.die(`unsupported test platform: ${platform.os}-${platform.arch}`); - } - return join(cacheRoot, "bin", "auth", DEFAULT_VERSIONS.auth, release.assetName); - }); - -afterEach(() => { - for (const root of tempRoots.splice(0)) { - rmSync(root, { recursive: true, force: true }); - } -}); + it.live("installs slim archives without requiring an external zstd executable", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const spawnedCommands: Array<{ command: string; args: ReadonlyArray }> = []; + const resolverLayer = makeResolverLayer(root, makeFixture(root), { spawnedCommands }); + yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + + expect(spawnedCommands.some(({ args }) => args.includes("--zstd"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("accepts the Mailpit-style glibc runtime requirement", () => + Effect.gen(function* () { + if (process.platform !== "linux") return; + const root = makeRoot(); + try { + const fixture = makeFixture(root, { runtime_requires: "glibc" }); + const resolverLayer = makeResolverLayer(root, fixture); + const result = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + expect(existsSync(join(result, "bin/postgrest"))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("reclaims interrupted staging while preserving complete cache entries", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root); + const resolverLayer = makeResolverLayer(root, fixture); + const release = nativeReleaseForService( + "postgrest", + DEFAULT_VERSIONS.postgrest, + yield* detectPlatform, + ); + if (release === undefined) return; + const cacheDir = BinaryResolver.cachePath(join(root, "bin"), { + service: "postgrest", + releaseSet: "slim-services", + version: DEFAULT_VERSIONS.postgrest, + runtime: "native", + target: release.target, + }); + const stale = join(dirname(cacheDir), `.${release.assetName}.partial-interrupted`); + mkdirSync(stale, { recursive: true }); + const old = new Date(Date.now() - 2 * 24 * 60 * 60 * 1_000); + utimesSync(stale, old, old); + + const resolved = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + + expect(existsSync(stale)).toBe(false); + expect(existsSync(join(resolved, ".complete"))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("replaces caches with invalid identity markers or missing required paths", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root); + const resolverLayer = makeResolverLayer(root, fixture); + const result = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + expect(result.downloaded).toBe(true); + + const markerPath = join(result.path, ".complete"); + writeFileSync(markerPath, JSON.stringify({ service: "postgrest" })); + const replacedMarker = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + expect(replacedMarker.downloaded).toBe(true); + expect(JSON.parse(readFileSync(markerPath, "utf8"))).toMatchObject({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + runtime: "native", + }); + + rmSync(join(replacedMarker.path, "bin/postgrest")); + const restoredPath = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + expect(restoredPath.downloaded).toBe(true); + expect(existsSync(join(restoredPath.path, "bin/postgrest"))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("preserves a cache published while another resolver repairs stale state", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root); + const resolverLayer = makeResolverLayer(root, fixture); + const installed = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + const markerPath = join(installed.path, ".complete"); + writeFileSync(markerPath, JSON.stringify({ service: "postgrest" })); + + const staleMarkerRead = yield* Deferred.make(); + const releaseStaleReader = yield* Deferred.make(); + let markerReads = 0; + const staleReaderLayer = makeResolverLayer(root, fixture, { + transformFileSystem: (fileSystem) => + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (requestedPath, options) => + Effect.gen(function* () { + const contents = yield* fileSystem.readFileString(requestedPath, options); + if (requestedPath === markerPath) { + markerReads += 1; + if (markerReads === 2) { + yield* Deferred.succeed(staleMarkerRead, undefined); + yield* Deferred.await(releaseStaleReader); + } + } + return contents; + }), + }), + }); + const staleReader = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(staleReaderLayer), Effect.forkChild); + yield* Deferred.await(staleMarkerRead); + + const publisher = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(makeResolverLayer(root, fixture))); + yield* Deferred.succeed(releaseStaleReader, undefined); + const repaired = yield* Fiber.join(staleReader); + + expect(publisher.downloaded).toBe(true); + expect(repaired.downloaded).toBe(false); + expect(repaired.path).toBe(installed.path); + expect(readFileSync(join(installed.path, "bin/postgrest"), "utf8")).toContain("postgrest"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("rejects archive members that escape the private staging directory", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeTraversalFixture(root); + const resolverLayer = makeResolverLayer(root, fixture); + const failure = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer), Effect.flip); + expect(failure).toBeInstanceOf(DownloadError); + const release = nativeReleaseForService( + "postgrest", + DEFAULT_VERSIONS.postgrest, + yield* detectPlatform, + ); + if (release !== undefined) { + const cache = BinaryResolver.cachePath(join(root, "bin"), { + service: "postgrest", + releaseSet: "slim-services", + version: DEFAULT_VERSIONS.postgrest, + runtime: "native", + target: release.target, + }); + expect(existsSync(join(dirname(cache), "outside.txt"))).toBe(false); + expect(existsSync(join(cache, ".complete"))).toBe(false); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); -describe("BinaryResolver cache publication", () => { - it.live("publishes one complete cache entry for concurrent resolvers", () => { - const root = makeTempRoot(); - const archive = makeArchive(root); - let requestCount = 0; - const layer = makeResolverLayer(root, archive, () => { - requestCount += 1; - }); - - return Effect.gen(function* () { - const resolver = yield* BinaryResolver; - const results = yield* Effect.all( - [ - resolver.resolveWithMetadata({ service: "auth", version: DEFAULT_VERSIONS.auth }), - resolver.resolveWithMetadata({ service: "auth", version: DEFAULT_VERSIONS.auth }), - ], - { concurrency: "unbounded" }, - ); - - expect(requestCount).toBe(2); - expect(results.filter((result) => result.downloaded)).toHaveLength(1); - expect(results[0]?.path).toBe(results[1]?.path); - expect(readFileSync(join(results[0]!.path, "auth"), "utf8")).toContain("echo auth"); - expect(readFileSync(join(results[0]!.path, ".complete"), "utf8")).toContain( - "github.com/supabase/auth", - ); - }).pipe(Effect.provide(layer)); - }); - - it.live("reuses a complete cache from the legacy layout without downloading", () => { - const root = makeTempRoot(); - const layer = makeUnavailableResolverLayer(root); - - return Effect.gen(function* () { - const resolver = yield* BinaryResolver; - const legacyCacheDir = yield* legacyAuthCachePath(root); - mkdirSync(legacyCacheDir, { recursive: true }); - writeFileSync(join(legacyCacheDir, "auth"), "legacy auth binary"); - - const result = yield* resolver.resolveWithMetadata({ - service: "auth", - version: DEFAULT_VERSIONS.auth, - }); - - expect(result).toEqual({ path: legacyCacheDir, downloaded: false }); - }).pipe(Effect.provide(layer)); - }); - - it.live("preserves a markerless provider cache when replacement download fails", () => { - const root = makeTempRoot(); - const layer = makeUnavailableResolverLayer(root); - - return Effect.gen(function* () { - const resolver = yield* BinaryResolver; - const cacheDir = yield* authCachePath(root); - const legacyBinary = join(cacheDir, "auth"); - mkdirSync(cacheDir, { recursive: true }); - writeFileSync(legacyBinary, "legacy auth binary"); - - const error = yield* resolver - .resolveWithMetadata({ service: "auth", version: DEFAULT_VERSIONS.auth }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(DownloadError); - expect(readFileSync(legacyBinary, "utf8")).toBe("legacy auth binary"); - }).pipe(Effect.provide(layer)); - }); - - it.live("replaces an incomplete provider cache after staging succeeds", () => { - const root = makeTempRoot(); - const archive = makeArchive(root); - const layer = makeResolverLayer(root, archive, () => {}); - - return Effect.gen(function* () { - const resolver = yield* BinaryResolver; - const cacheDir = yield* authCachePath(root); - mkdirSync(cacheDir, { recursive: true }); - writeFileSync(join(cacheDir, ".complete"), "orphaned marker"); - - const result = yield* resolver.resolveWithMetadata({ - service: "auth", - version: DEFAULT_VERSIONS.auth, - }); - - expect(result).toEqual({ path: cacheDir, downloaded: true }); - expect(readFileSync(join(cacheDir, "auth"), "utf8")).toContain("echo auth"); - expect(readFileSync(join(cacheDir, ".complete"), "utf8")).toContain( - "github.com/supabase/auth", - ); - }).pipe(Effect.provide(layer)); - }); - - it.live("reaps stale staging directories even when the artifact is cached", () => { - const root = makeTempRoot(); - const archive = makeArchive(root); - const layer = makeResolverLayer(root, archive, () => {}); - - return Effect.gen(function* () { - const resolver = yield* BinaryResolver; - const spec = { service: "auth", version: DEFAULT_VERSIONS.auth } as const; - const first = yield* resolver.resolveWithMetadata(spec); - const staleStaging = join(dirname(first.path), `.${basename(first.path)}.partial-abandoned`); - mkdirSync(staleStaging); - writeFileSync(join(staleStaging, "partial"), "partial artifact"); - const staleTime = new Date(Date.now() - 25 * 60 * 60 * 1_000); - utimesSync(staleStaging, staleTime, staleTime); - - const second = yield* resolver.resolveWithMetadata(spec); - - expect(second.downloaded).toBe(false); - expect(existsSync(staleStaging)).toBe(false); - }).pipe(Effect.provide(layer)); - }); + it.live("rejects archive symlinks that resolve outside private staging", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const resolverLayer = makeResolverLayer(root, makeEscapingSymlinkFixture(root)); + const failure = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer), Effect.flip); + expect(failure).toBeInstanceOf(BinaryRuntimeError); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("rejects checksum and manifest/runtime validation failures", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root); + const resolverLayer = makeResolverLayer(root, fixture, { checksum: "0".repeat(64) }); + const checksum = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer), Effect.flip); + expect(checksum).toBeInstanceOf(ChecksumMismatchError); + + const manifestLayer = makeResolverLayer( + root, + makeFixture(root, { + target: process.platform === "darwin" ? "linux-amd64" : "darwin-arm64", + }), + ); + const manifest = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(manifestLayer), Effect.flip); + expect(manifest).toBeInstanceOf(BinaryManifestError); + expect(manifest).not.toBeInstanceOf(BinaryRuntimeError); + + const unsafeCommandLayer = makeResolverLayer( + root, + makeFixture(root, { cmd: ["../bin/postgrest"] }), + ); + const unsafeCommand = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(unsafeCommandLayer), Effect.flip); + expect(unsafeCommand).toBeInstanceOf(BinaryManifestError); + + const runtimeLayer = makeResolverLayer(root, makeFixture(root, {}, false)); + const runtime = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(runtimeLayer), Effect.flip); + expect(runtime).toBeInstanceOf(BinaryRuntimeError); + const release = nativeReleaseForService( + "postgrest", + DEFAULT_VERSIONS.postgrest, + yield* detectPlatform, + ); + if (release !== undefined) { + const cache = BinaryResolver.cachePath(join(root, "bin"), { + service: "postgrest", + releaseSet: "slim-services", + version: DEFAULT_VERSIONS.postgrest, + runtime: "native", + target: release.target, + }); + expect(existsSync(join(cache, ".complete"))).toBe(false); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("fails closed when SHA256SUMS has no entry for the requested archive", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root); + const unrelated = createHash("sha256").update(fixture.archive).digest("hex"); + const resolverLayer = makeResolverLayer(root, fixture, { + checksumText: `${unrelated} unrelated.sbom.spdx.json\n`, + }); + const failure = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer), Effect.flip); + + expect(failure).toBeInstanceOf(BinaryManifestError); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + + it.live("accepts uppercase SHA256SUMS digests", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root); + const hash = createHash("sha256").update(fixture.archive).digest("hex").toUpperCase(); + const resolverLayer = makeResolverLayer(root, fixture, { checksum: hash }); + const resolved = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + return yield* resolver.resolve({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + }).pipe(Effect.provide(resolverLayer)); + + expect(existsSync(join(resolved, "bin/postgrest"))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); }); diff --git a/packages/stack/src/BinaryResolver.ts b/packages/stack/src/BinaryResolver.ts index f6ee4070d4..d6e4c9de70 100644 --- a/packages/stack/src/BinaryResolver.ts +++ b/packages/stack/src/BinaryResolver.ts @@ -1,14 +1,29 @@ import { createHash } from "node:crypto"; -import { Context, Effect, FileSystem, Layer, Option, Path, Result } from "effect"; +import { zstdDecompressSync } from "node:zlib"; +import { + Context, + Duration, + Effect, + FileSystem, + Layer, + Option, + Path, + PlatformError, + Result, + Schedule, +} from "effect"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { BinaryNotFoundError, ChecksumMismatchError, DownloadError } from "./errors.ts"; -import { detectPlatform } from "./Platform.ts"; import { - nativeReleaseForService, - type ArchiveFormat, - type NativeReleaseArtifact, -} from "./ServiceCatalog.ts"; + BinaryHostCompatibilityError, + BinaryManifestError, + BinaryNotFoundError, + BinaryRuntimeError, + ChecksumMismatchError, + DownloadError, +} from "./errors.ts"; +import { detectPlatform, type NativeTarget } from "./Platform.ts"; +import { nativeReleaseForService, type NativeReleaseArtifact } from "./ServiceCatalog.ts"; import type { ServiceName } from "./ServiceName.ts"; export interface BinarySpec { @@ -22,89 +37,80 @@ interface ResolveBinaryResult { readonly downloaded: boolean; } +interface SlimServiceManifest { + readonly service: string; + readonly version: string; + readonly target: string; + readonly entrypoint: ReadonlyArray; + readonly cmd: ReadonlyArray; + readonly runtime_requires?: null | "glibc"; + readonly libc: null | "glibc"; + readonly os_floor: null | { + readonly kind: string; + readonly floor: string | null; + readonly offender?: string | null; + readonly scanned: number; + readonly bundled_glibc?: boolean; + }; +} + export interface ResolveBinaryOptions { readonly onDownloadStart?: Effect.Effect; } interface AssetInfo { readonly service: ServiceName; - readonly provider: string; + readonly releaseSet: "slim-services"; readonly version: string; - readonly assetName: string; + readonly runtime: "native"; + readonly target: NativeTarget; } -const cachePath = (baseDir: string, info: AssetInfo): string => - `${baseDir}/${info.service}/${info.provider.replaceAll("/", "_")}/${info.version}/${info.assetName}`; - -const LEGACY_NATIVE_PROVIDERS: Partial> = { - postgres: "github.com/supabase/postgres", - postgrest: "github.com/PostgREST/postgrest", - auth: "github.com/supabase/auth", - "edge-runtime": "github.com/supabase/edge-runtime", -}; +interface HostCompatibilityRequirement { + readonly runtimeRequires: null | "glibc"; + readonly libc: null | "glibc"; + readonly osFloor: null | { + readonly kind: "glibc" | "macos"; + readonly floor: string | null; + }; +} -const legacyCachePath = (baseDir: string, info: AssetInfo): string | undefined => - LEGACY_NATIVE_PROVIDERS[info.service] === info.provider - ? `${baseDir}/${info.service}/${info.version}/${info.assetName}` - : undefined; - -const legacyExecutablePath = ( - directory: string, - service: ServiceName, - platformOs: string, -): string | undefined => { - const executableSuffix = platformOs === "win32" ? ".exe" : ""; - switch (service) { - case "postgres": - return `${directory}/bin/postgres${executableSuffix}`; - case "postgrest": - return `${directory}/postgrest${executableSuffix}`; - case "auth": - return `${directory}/auth${executableSuffix}`; - case "edge-runtime": - return `${directory}/bin/edge-runtime${executableSuffix}`; - default: - return undefined; - } -}; +interface CacheCompleteMarker { + readonly provider: string; + readonly service: string; + readonly version: string; + readonly asset: string; + readonly url: string; + readonly target: NativeTarget; + readonly releaseSet: "slim-services"; + readonly runtime: "native"; + readonly hostCompatibility: HostCompatibilityRequirement; +} -const legacyCacheRequiredPaths = ( - directory: string, - service: ServiceName, - platformOs: string, -): ReadonlyArray => { - const executable = legacyExecutablePath(directory, service, platformOs); - if (executable === undefined) return []; - return service === "postgres" - ? [ - executable, - `${directory}/bin/pg_isready${platformOs === "win32" ? ".exe" : ""}`, - `${directory}/bin/psql${platformOs === "win32" ? ".exe" : ""}`, - `${directory}/share/supabase-cli/bin/supabase-postgres-init.sh`, - `${directory}/lib`, - ] - : [executable]; -}; +const cachePath = (baseDir: string, info: AssetInfo): string => + `${baseDir}/${info.releaseSet}/${info.service}/${info.version}/${info.runtime}/${info.target}`; const CACHE_COMPLETE_MARKER = ".complete"; -const STALE_STAGING_AGE_MS = 24 * 60 * 60 * 1_000; - -const extractCommand = ( - archive: ArchiveFormat, - archivePath: string, - destDir: string, - os: string, - stripComponents: boolean, -): string[] => { - if (archive === "zip") { - return os === "win32" - ? ["tar", "xf", archivePath, "-C", destDir] - : ["unzip", "-o", archivePath, "-d", destDir]; +const STALE_PREPARATION_ENTRY_AGE_MS = 24 * 60 * 60 * 1_000; + +const hasTraversalSegment = (value: string): boolean => + value.split(/[\\/]/).some((segment) => segment === ".."); + +const isUnsafeArchiveMember = (member: string): boolean => { + const normalized = member.trim(); + if (normalized.length === 0) return false; + if (normalized.startsWith("/") || /^[A-Za-z]:[\\/]/.test(normalized)) return true; + let depth = 0; + for (const segment of normalized.split(/[\\/]/)) { + if (segment.length === 0 || segment === ".") continue; + if (segment === "..") { + if (depth === 0) return true; + depth -= 1; + } else { + depth += 1; + } } - const flag = archive === "tar.gz" ? "xzf" : "xf"; - const args = ["tar", flag, archivePath, "-C", destDir]; - if (stripComponents) args.push("--strip-components=1"); - return args; + return false; }; const verifyChecksum = ( @@ -115,7 +121,7 @@ const verifyChecksum = ( Effect.sync(() => { const actual = createHash("sha256").update(new Uint8Array(data)).digest("hex"); // The .sha256 file typically contains "hex filename" or just "hex" - const expectedHex = expected.trim().split(/\s+/)[0] ?? ""; + const expectedHex = (expected.trim().split(/\s+/)[0] ?? "").toLowerCase(); return { actual, expectedHex }; }).pipe( Effect.flatMap(({ actual, expectedHex }) => { @@ -126,25 +132,277 @@ const verifyChecksum = ( }), ); +const checksumForArchive = (contents: string, archiveName: string): string | undefined => { + for (const line of contents.split(/\r?\n/)) { + const match = line.trim().match(/^([a-f0-9]{64})\s+[* ]?(.+)$/i); + if (match?.[2] === archiveName || match?.[2]?.endsWith(`/${archiveName}`)) { + return match[1]?.toLowerCase(); + } + } + return undefined; +}; + +const manifestError = (url: string, detail: string): BinaryManifestError => + new BinaryManifestError({ url, detail }); + +const isSlimServiceManifest = (value: unknown): value is SlimServiceManifest => { + if (typeof value !== "object" || value === null) return false; + if (!("service" in value) || typeof value.service !== "string") return false; + if (!("version" in value) || typeof value.version !== "string") return false; + if (!("target" in value) || typeof value.target !== "string") return false; + if (!("entrypoint" in value) || !Array.isArray(value.entrypoint)) return false; + if (!("cmd" in value) || !Array.isArray(value.cmd)) return false; + if ( + "runtime_requires" in value && + value.runtime_requires !== null && + value.runtime_requires !== "glibc" + ) + return false; + if (!("libc" in value) || (value.libc !== null && value.libc !== "glibc")) return false; + if (!("os_floor" in value)) return false; + if (value.os_floor !== null) { + if (typeof value.os_floor !== "object") return false; + if (!("kind" in value.os_floor) || typeof value.os_floor.kind !== "string") return false; + if (!("floor" in value.os_floor)) return false; + if (value.os_floor.floor !== null && typeof value.os_floor.floor !== "string") return false; + if (!("scanned" in value.os_floor) || typeof value.os_floor.scanned !== "number") return false; + if ( + "offender" in value.os_floor && + value.os_floor.offender !== null && + typeof value.os_floor.offender !== "string" + ) + return false; + if ("bundled_glibc" in value.os_floor && typeof value.os_floor.bundled_glibc !== "boolean") + return false; + } + return true; +}; + +const validateManifest = ( + release: NativeReleaseArtifact, + raw: unknown, + platform: { readonly os: string; readonly arch: string }, + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], +): Effect.Effect< + HostCompatibilityRequirement, + BinaryManifestError | BinaryHostCompatibilityError +> => + Effect.gen(function* () { + if (typeof raw !== "object" || raw === null) { + return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest must be an object")); + } + if (!isSlimServiceManifest(raw)) { + return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest schema is invalid")); + } + const manifest = raw; + if ( + manifest.service !== release.service || + manifest.version !== release.version || + manifest.target !== release.target + ) { + return yield* Effect.fail( + manifestError( + release.manifestUrl, + "Manifest service/version/target does not match release", + ), + ); + } + if ( + !Array.isArray(manifest.entrypoint) || + !manifest.entrypoint.every((value) => typeof value === "string") || + !Array.isArray(manifest.cmd) || + !manifest.cmd.every((value) => typeof value === "string") + ) { + return yield* Effect.fail( + manifestError(release.manifestUrl, "Manifest entrypoint/cmd must be string arrays"), + ); + } + if (manifest.entrypoint.length === 0 && manifest.cmd.length === 0) { + return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest has no command")); + } + const runtimeRequires = manifest.runtime_requires ?? null; + const commandPaths = [...manifest.entrypoint, ...manifest.cmd].filter( + (entry) => + entry.startsWith("/") || + entry.includes("/") || + entry.includes("\\") || + entry === "." || + entry === "..", + ); + if (commandPaths.some((entry) => hasTraversalSegment(entry))) { + return yield* Effect.fail( + manifestError(release.manifestUrl, "Manifest command path is unsafe"), + ); + } + const osFloor = manifest.os_floor; + if (osFloor !== null && typeof osFloor !== "object") { + return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest os_floor is invalid")); + } + if (osFloor !== null && osFloor.kind !== "macos" && osFloor.kind !== "glibc") { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target: release.target, + detail: `Unsupported manifest host kind ${osFloor.kind}`, + }), + ); + } + const hostCompatibility: HostCompatibilityRequirement = { + runtimeRequires, + libc: manifest.libc, + osFloor: + osFloor === null + ? null + : osFloor.kind === "macos" + ? { kind: "macos", floor: osFloor.floor } + : { kind: "glibc", floor: osFloor.floor }, + }; + yield* validateHostCompatibility(release.target, hostCompatibility, platform, spawner); + return hostCompatibility; + }); + +const validateHostCompatibility = ( + target: NativeTarget, + requirement: HostCompatibilityRequirement, + platform: { readonly os: string; readonly arch: string }, + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], +): Effect.Effect => + Effect.gen(function* () { + const requiresGlibc = + requirement.libc === "glibc" || + requirement.runtimeRequires === "glibc" || + requirement.osFloor?.kind === "glibc"; + if (requirement.osFloor?.kind === "macos" && platform.os !== "darwin") { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target, + detail: "Manifest requires macOS", + }), + ); + } + if (requiresGlibc && platform.os !== "linux") { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target, + detail: "Manifest requires Linux/glibc", + }), + ); + } + const floor = requirement.osFloor?.floor; + if (requiresGlibc) { + const host = yield* Effect.sync(() => { + try { + const report = process.report?.getReport?.(); + if (typeof report !== "object" || report === null || !("header" in report)) { + return undefined; + } + const header = report.header; + if ( + typeof header !== "object" || + header === null || + !("glibcVersionRuntime" in header) || + typeof header.glibcVersionRuntime !== "string" + ) { + return undefined; + } + return header.glibcVersionRuntime; + } catch { + return undefined; + } + }); + if (typeof host !== "string" || host.trim().length === 0) { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target, + detail: "Unable to determine host glibc version", + }), + ); + } + if (floor !== null && floor !== undefined && compareVersions(host, floor) < 0) { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target, + detail: `Host glibc ${host} is below manifest floor ${floor}`, + }), + ); + } + } + if ( + platform.os === "darwin" && + requirement.osFloor?.kind === "macos" && + floor !== null && + floor !== undefined + ) { + const host = yield* spawner.string(ChildProcess.make("sw_vers", ["-productVersion"])).pipe( + Effect.mapError( + (cause) => + new BinaryHostCompatibilityError({ + target, + detail: `Unable to determine macOS version: ${String(cause)}`, + }), + ), + ); + const hostVersion = host.trim().split(/\s+/)[0] ?? ""; + if (hostVersion.length === 0) { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target, + detail: "Unable to determine macOS version", + }), + ); + } + if (compareVersions(hostVersion, floor) < 0) { + return yield* Effect.fail( + new BinaryHostCompatibilityError({ + target, + detail: `Host macOS ${hostVersion} is below manifest floor ${floor}`, + }), + ); + } + } + }); + +const compareVersions = (left: string, right: string): number => { + const a = left.split(".").map((part) => Number(part) || 0); + const b = right.split(".").map((part) => Number(part) || 0); + for (let index = 0; index < Math.max(a.length, b.length); index += 1) { + const diff = (a[index] ?? 0) - (b[index] ?? 0); + if (diff !== 0) return diff; + } + return 0; +}; + export class BinaryResolver extends Context.Service< BinaryResolver, { + /** Computes the immutable cache identity without inspecting or changing the filesystem. */ + readonly plan: (spec: BinarySpec) => Effect.Effect; readonly resolveWithMetadata: ( spec: BinarySpec, options?: ResolveBinaryOptions, ) => Effect.Effect< ResolveBinaryResult, - BinaryNotFoundError | DownloadError | ChecksumMismatchError + | BinaryNotFoundError + | DownloadError + | ChecksumMismatchError + | BinaryManifestError + | BinaryRuntimeError + | BinaryHostCompatibilityError >; readonly resolve: ( spec: BinarySpec, - ) => Effect.Effect; + ) => Effect.Effect< + string, + | BinaryNotFoundError + | DownloadError + | ChecksumMismatchError + | BinaryManifestError + | BinaryRuntimeError + | BinaryHostCompatibilityError + >; } >()("local/BinaryResolver") { // Static pure functions — tested in unit tests static cachePath = cachePath; - static legacyExecutablePath = legacyExecutablePath; - static legacyCacheRequiredPaths = legacyCacheRequiredPaths; static make( cacheRoot: string, @@ -165,29 +423,123 @@ export class BinaryResolver extends Context.Service< const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const isCompleteCache = (directory: string) => + const isCacheCompleteMarker = (value: unknown): value is CacheCompleteMarker => { + if (typeof value !== "object" || value === null) return false; + if (!("provider" in value) || typeof value.provider !== "string") return false; + if (!("service" in value) || typeof value.service !== "string") return false; + if (!("version" in value) || typeof value.version !== "string") return false; + if (!("asset" in value) || typeof value.asset !== "string") return false; + if (!("url" in value) || typeof value.url !== "string") return false; + if ( + !("target" in value) || + (value.target !== "darwin-arm64" && + value.target !== "linux-amd64" && + value.target !== "linux-arm64") + ) + return false; + if (!("releaseSet" in value) || value.releaseSet !== "slim-services") return false; + if (!("runtime" in value) || value.runtime !== "native") return false; + if (!("hostCompatibility" in value)) return false; + const host = value.hostCompatibility; + if (typeof host !== "object" || host === null) return false; + if ( + !("runtimeRequires" in host) || + (host.runtimeRequires !== null && host.runtimeRequires !== "glibc") + ) + return false; + if (!("libc" in host) || (host.libc !== null && host.libc !== "glibc")) return false; + if (!("osFloor" in host)) return false; + if (host.osFloor !== null) { + if (typeof host.osFloor !== "object") return false; + if ( + !("kind" in host.osFloor) || + (host.osFloor.kind !== "glibc" && host.osFloor.kind !== "macos") + ) + return false; + if ( + !("floor" in host.osFloor) || + (host.osFloor.floor !== null && typeof host.osFloor.floor !== "string") + ) + return false; + } + return true; + }; + + const isCompleteCache = ( + directory: string, + release: NativeReleaseArtifact, + info: AssetInfo, + platform: { readonly os: string; readonly arch: string }, + ) => Effect.gen(function* () { - if (!(yield* fs.exists(path.join(directory, CACHE_COMPLETE_MARKER)))) { + const marker = yield* fs + .readFileString(path.join(directory, CACHE_COMPLETE_MARKER)) + .pipe(Effect.option); + if (Option.isNone(marker)) return false; + const parsed = yield* Effect.sync(() => { + try { + const value: unknown = JSON.parse(marker.value); + return value; + } catch { + return undefined; + } + }); + if (!isCacheCompleteMarker(parsed)) return false; + if ( + parsed.provider !== release.provider || + parsed.service !== info.service || + parsed.version !== info.version || + parsed.asset !== release.assetName || + parsed.url !== release.downloadUrl || + parsed.target !== info.target || + parsed.releaseSet !== info.releaseSet || + parsed.runtime !== info.runtime + ) { return false; } - const entries = yield* fs.readDirectory(directory); - return entries.some((entry) => entry !== CACHE_COMPLETE_MARKER); - }); + const requiredPaths = [...new Set(release.requiredRuntimePaths)]; + const present = yield* Effect.forEach(requiredPaths, (entry) => + fs.exists(path.join(directory, entry)), + ); + if (!present.every(Boolean)) return false; + yield* validateHostCompatibility( + release.target, + parsed.hostCompatibility, + platform, + spawner, + ); + return true; + }).pipe(Effect.catchTag("PlatformError", () => Effect.succeed(false))); - const isReusableLegacyCache = ( - directory: string, - service: ServiceName, - platformOs: string, - ) => { - const requiredPaths = legacyCacheRequiredPaths(directory, service, platformOs); - return requiredPaths.length === 0 - ? Effect.succeed(false) - : Effect.forEach(requiredPaths, fs.exists).pipe( - Effect.map((results) => results.every(Boolean)), + const resolveRelease = (spec: BinarySpec) => + Effect.gen(function* () { + const platform = yield* detectPlatform; + const release = nativeReleaseForService(spec.service, spec.version, platform); + if (release === undefined) { + return yield* Effect.fail( + new BinaryNotFoundError({ + service: spec.service, + platform: `${platform.os}-${platform.arch}`, + }), ); - }; + } + const info: AssetInfo = { + service: spec.service, + releaseSet: "slim-services", + version: spec.version, + runtime: "native", + target: release.target, + }; + return { platform, release, info }; + }); - const cleanupStaleStaging = (directory: string, prefix: string) => + const plan = (spec: BinarySpec): Effect.Effect => + Effect.gen(function* () { + const { info } = yield* resolveRelease(spec); + return cachePath(spec.cacheDir ?? binDir, info); + }); + + const cleanupStaleEntries = (directory: string, prefix: string) => fs.readDirectory(directory).pipe( Effect.flatMap((entries) => Effect.forEach( @@ -199,7 +551,7 @@ export class BinaryResolver extends Context.Service< Option.match(info.mtime, { onNone: () => Effect.void, onSome: (modifiedAt) => - Date.now() - modifiedAt.getTime() >= STALE_STAGING_AGE_MS + Date.now() - modifiedAt.getTime() >= STALE_PREPARATION_ENTRY_AGE_MS ? fs.remove(stagingPath, { recursive: true, force: true }) : Effect.void, }), @@ -213,12 +565,64 @@ export class BinaryResolver extends Context.Service< Effect.ignore, ); + const validateExtractedTree = (directory: string) => + Effect.gen(function* () { + const root = yield* fs.realPath(directory); + const entries = yield* fs.readDirectory(directory, { recursive: true }); + for (const entry of entries) { + const candidate = path.join(directory, entry); + const resolved = yield* fs.realPath(candidate).pipe( + Effect.mapError( + () => + new BinaryRuntimeError({ + path: candidate, + detail: "Extracted path cannot be resolved inside private staging", + }), + ), + ); + const relative = path.relative(root, resolved); + if ( + path.isAbsolute(relative) || + relative === ".." || + relative.startsWith(`..${path.sep}`) + ) { + return yield* Effect.fail( + new BinaryRuntimeError({ + path: candidate, + detail: `Extracted path resolves outside private staging: ${entry}`, + }), + ); + } + } + }); + const extractRelease = ( release: NativeReleaseArtifact, destination: string, - platformOs: string, + platform: { readonly os: string; readonly arch: string }, ) => Effect.gen(function* () { + const manifestResponse = yield* httpClient + .get(release.manifestUrl) + .pipe( + Effect.catchTag("HttpClientError", (cause) => + Effect.fail(new DownloadError({ url: release.manifestUrl, cause })), + ), + ); + const manifestText = yield* manifestResponse.text.pipe( + Effect.catchTag("HttpClientError", (cause) => + Effect.fail(new DownloadError({ url: release.manifestUrl, cause })), + ), + ); + const hostCompatibility = yield* Effect.try({ + try: () => { + const parsed: unknown = JSON.parse(manifestText); + return parsed; + }, + catch: (cause) => + manifestError(release.manifestUrl, `Invalid JSON: ${String(cause)}`), + }).pipe(Effect.flatMap((value) => validateManifest(release, value, platform, spawner))); + const tarballResponse = yield* httpClient .get(release.downloadUrl) .pipe( @@ -232,43 +636,60 @@ export class BinaryResolver extends Context.Service< ), ); - const checksumUrl = release.checksumUrl; - if (checksumUrl !== null) { - const checksumResponse = yield* httpClient - .get(checksumUrl) - .pipe( - Effect.catchTag("HttpClientError", (cause) => - Effect.fail(new DownloadError({ url: checksumUrl, cause })), - ), - ); - const checksumText = yield* checksumResponse.text.pipe( + const checksumResponse = yield* httpClient + .get(release.checksumUrl) + .pipe( Effect.catchTag("HttpClientError", (cause) => - Effect.fail(new DownloadError({ url: checksumUrl, cause })), + Effect.fail(new DownloadError({ url: release.checksumUrl, cause })), ), ); - yield* verifyChecksum(tarball, checksumText, checksumUrl); + const checksumText = yield* checksumResponse.text.pipe( + Effect.catchTag("HttpClientError", (cause) => + Effect.fail(new DownloadError({ url: release.checksumUrl, cause })), + ), + ); + const expected = checksumForArchive(checksumText, `${release.assetName}.tar.zst`); + if (expected === undefined) { + return yield* Effect.fail( + manifestError(release.checksumUrl, "SHA256SUMS has no entry for the archive"), + ); } + yield* verifyChecksum(tarball, expected, release.checksumUrl); - const archivePath = path.join(destination, `_download.${release.archive}`); - yield* fs.writeFile(archivePath, new Uint8Array(tarball)); + const archivePath = path.join(destination, "_download.tar"); + const archive = yield* Effect.try({ + try: () => zstdDecompressSync(new Uint8Array(tarball)), + catch: (cause) => new DownloadError({ url: release.downloadUrl, cause }), + }); + yield* fs.writeFile(archivePath, archive); - const [command, ...args] = extractCommand( - release.archive, - archivePath, - destination, - platformOs, - release.stripComponents, - ); - if (command === undefined) { + const members = yield* spawner + .string(ChildProcess.make("tar", ["-tf", archivePath])) + .pipe( + Effect.catch((cause) => + Effect.fail( + new DownloadError({ + url: release.downloadUrl, + cause, + }), + ), + ), + ); + const unsafeMember = members + .split(/\r?\n/) + .map((member) => member.trim()) + .find(isUnsafeArchiveMember); + if (unsafeMember !== undefined) { return yield* Effect.fail( new DownloadError({ url: release.downloadUrl, - cause: new Error("No extraction command was configured"), + cause: new Error(`archive member is unsafe: ${unsafeMember}`), }), ); } + const exitCode = yield* spawner - .exitCode(ChildProcess.make(command, args)) + .exitCode(ChildProcess.make("tar", ["-xf", archivePath, "-C", destination])) .pipe( Effect.catchTag("PlatformError", (cause) => Effect.fail(new DownloadError({ url: release.downloadUrl, cause })), @@ -283,15 +704,17 @@ export class BinaryResolver extends Context.Service< ); } + yield* validateExtractedTree(destination); + yield* fs.remove(archivePath).pipe(Effect.ignore); - if (platformOs !== "win32") { + if (platform.os !== "win32") { yield* spawner .exitCode(ChildProcess.make("chmod", ["-R", "u+x", destination])) .pipe(Effect.ignore); } - if (platformOs === "darwin") { + if (platform.os === "darwin") { yield* spawner .exitCode( ChildProcess.make("find", [ @@ -316,49 +739,38 @@ export class BinaryResolver extends Context.Service< ) .pipe(Effect.ignore); } - }); - const resolveWithMetadata = (spec: BinarySpec, options?: ResolveBinaryOptions) => { - const core = Effect.gen(function* () { - const platform = yield* detectPlatform; - const release = nativeReleaseForService(spec.service, spec.version, platform); - if (release === undefined) { + const requiredPaths = [...new Set(release.requiredRuntimePaths)]; + const missing = yield* Effect.forEach(requiredPaths, (entry) => + fs.exists(path.join(destination, entry)), + ).pipe(Effect.map((exists) => requiredPaths.filter((_entry, index) => !exists[index]))); + if (missing.length > 0) { return yield* Effect.fail( - new BinaryNotFoundError({ - service: spec.service, - platform: `${platform.os}-${platform.arch}`, + new BinaryRuntimeError({ + path: destination, + detail: `Manifest runtime paths are missing: ${missing.join(", ")}`, }), ); } + return hostCompatibility; + }); - const info: AssetInfo = { - service: spec.service, - provider: release.provider, - version: spec.version, - assetName: release.assetName, - }; + const resolveWithMetadata = (spec: BinarySpec, options?: ResolveBinaryOptions) => { + const core = Effect.gen(function* () { + const { platform, release, info } = yield* resolveRelease(spec); const baseDir = spec.cacheDir ?? binDir; const cacheDir = cachePath(baseDir, info); - const legacyDir = legacyCachePath(baseDir, info); const parentDir = path.dirname(cacheDir); const stagingPrefix = `.${release.assetName}.partial-`; - yield* cleanupStaleStaging(parentDir, stagingPrefix); - if (yield* isCompleteCache(cacheDir)) { + const publicationLock = path.join(parentDir, `.${release.assetName}.publication-lock`); + yield* cleanupStaleEntries(parentDir, stagingPrefix); + yield* cleanupStaleEntries(parentDir, path.basename(publicationLock)); + if (yield* isCompleteCache(cacheDir, release, info, platform)) { return { path: cacheDir, downloaded: false, } satisfies ResolveBinaryResult; } - if ( - legacyDir !== undefined && - (yield* isReusableLegacyCache(legacyDir, spec.service, platform.os)) - ) { - return { - path: legacyDir, - downloaded: false, - } satisfies ResolveBinaryResult; - } - yield* fs.makeDirectory(parentDir, { recursive: true }); yield* options?.onDownloadStart ?? Effect.void; @@ -367,7 +779,7 @@ export class BinaryResolver extends Context.Service< prefix: stagingPrefix, }); return yield* Effect.gen(function* () { - yield* extractRelease(release, stagingDir, platform.os); + const hostCompatibility = yield* extractRelease(release, stagingDir, platform); yield* fs.writeFile( path.join(stagingDir, CACHE_COMPLETE_MARKER), new TextEncoder().encode( @@ -377,6 +789,10 @@ export class BinaryResolver extends Context.Service< version: spec.version, asset: release.assetName, url: release.downloadUrl, + target: info.target, + releaseSet: info.releaseSet, + runtime: info.runtime, + hostCompatibility, }), ), ); @@ -388,23 +804,46 @@ export class BinaryResolver extends Context.Service< if (Result.isSuccess(publication)) { return { path: cacheDir, downloaded: true } satisfies ResolveBinaryResult; } - if (yield* isCompleteCache(cacheDir)) { + if (yield* isCompleteCache(cacheDir, release, info, platform)) { return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult; } - // A fully staged replacement is now available, so an incomplete - // destination can be reclaimed without risking the last usable - // cache entry. Retry publication once; persistent filesystem - // failures still surface instead of looping forever. - yield* fs.remove(cacheDir, { recursive: true, force: true }); - const retry = yield* fs.rename(stagingDir, cacheDir).pipe(Effect.result); - if (Result.isSuccess(retry)) { - return { path: cacheDir, downloaded: true } satisfies ResolveBinaryResult; - } - if (yield* isCompleteCache(cacheDir)) { - return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult; - } - return yield* Effect.fail(retry.failure); + return yield* Effect.scoped( + Effect.gen(function* () { + const acquirePublicationLock: Effect.Effect = + fs.makeDirectory(publicationLock).pipe( + Effect.retry({ + while: (error) => error.reason._tag === "AlreadyExists", + schedule: Schedule.recurs(1_200).pipe( + Schedule.addDelay(() => Effect.succeed(Duration.millis(25))), + ), + }), + ); + yield* Effect.acquireRelease(acquirePublicationLock, () => + fs + .remove(publicationLock, { recursive: true, force: true }) + .pipe(Effect.ignore), + ); + + // The destination may have changed while this resolver was + // waiting to repair it. Revalidate under the publication + // claim before removing anything shared. + if (yield* isCompleteCache(cacheDir, release, info, platform)) { + return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult; + } + + yield* fs.remove(cacheDir, { recursive: true, force: true }); + const retry = yield* fs.rename(stagingDir, cacheDir).pipe(Effect.result); + if (Result.isSuccess(retry)) { + return { path: cacheDir, downloaded: true } satisfies ResolveBinaryResult; + } + const retryFailure = retry.failure; + if (yield* isCompleteCache(cacheDir, release, info, platform)) { + return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult; + } + return yield* Effect.fail(retryFailure); + }), + ); }).pipe( Effect.ensuring( fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore), @@ -423,6 +862,7 @@ export class BinaryResolver extends Context.Service< }; return { + plan, resolveWithMetadata, resolve: (spec: BinarySpec) => { return Effect.map(resolveWithMetadata(spec), ({ path }) => path); diff --git a/packages/stack/src/BinaryResolver.unit.test.ts b/packages/stack/src/BinaryResolver.unit.test.ts index d0576010bd..5a6c930596 100644 --- a/packages/stack/src/BinaryResolver.unit.test.ts +++ b/packages/stack/src/BinaryResolver.unit.test.ts @@ -3,111 +3,46 @@ import { BinaryResolver } from "./BinaryResolver.ts"; import { nativeReleaseForService } from "./ServiceCatalog.ts"; import { DEFAULT_VERSIONS } from "./versions.ts"; -const postgresVersion = DEFAULT_VERSIONS.postgres; -const postgrestVersion = DEFAULT_VERSIONS.postgrest; -const authVersion = DEFAULT_VERSIONS.auth; -const authRcVersion = "2.188.0-rc.15"; -const edgeRuntimeVersion = DEFAULT_VERSIONS["edge-runtime"]; - -describe("nativeReleaseForService", () => { - it("constructs postgres URL (appends -cli suffix for native binaries)", () => { - const release = nativeReleaseForService("postgres", postgresVersion, { +describe("slim native release descriptors", () => { + it("uses the frozen slim-services archive, manifest, and checksum names", () => { + const release = nativeReleaseForService("postgrest", DEFAULT_VERSIONS.postgrest, { os: "darwin", arch: "arm64", }); - expect(release?.downloadUrl).toBe( - `https://github.com/supabase/postgres/releases/download/v${postgresVersion}-cli/supabase-postgres-v${postgresVersion}-cli-darwin-arm64.tar.gz`, - ); - expect(release?.checksumUrl).toBe(`${release?.downloadUrl}.sha256`); - expect(release?.stripComponents).toBe(true); - }); - - it("constructs postgrest URL", () => { - const release = nativeReleaseForService("postgrest", postgrestVersion, { - os: "darwin", - arch: "arm64", - }); - expect(release?.downloadUrl).toBe( - `https://github.com/PostgREST/postgrest/releases/download/v${postgrestVersion}/postgrest-v${postgrestVersion}-macos-aarch64.tar.xz`, - ); - }); - - it("constructs postgrest Windows URL with .zip extension", () => { - const release = nativeReleaseForService("postgrest", postgrestVersion, { - os: "win32", - arch: "x64", - }); - expect(release?.downloadUrl).toBe( - `https://github.com/PostgREST/postgrest/releases/download/v${postgrestVersion}/postgrest-v${postgrestVersion}-windows-x86-64.zip`, - ); - expect(release?.archive).toBe("zip"); - }); - - it("constructs auth URL for rc releases", () => { - const release = nativeReleaseForService("auth", authRcVersion, { - os: "linux", - arch: "arm64", - }); - expect(release?.downloadUrl).toBe( - `https://github.com/supabase/auth/releases/download/rc${authRcVersion}/auth-v${authRcVersion}-arm64.tar.gz`, - ); - }); - - it("constructs edge-runtime URL", () => { - const release = nativeReleaseForService("edge-runtime", edgeRuntimeVersion, { - os: "darwin", - arch: "arm64", + expect(release).toMatchObject({ + releaseTag: "postgrest-v16.1", + target: "darwin-arm64", + archive: "tar.zst", + downloadUrl: + "https://github.com/supabase/slim-services/releases/download/postgrest-v16.1/postgrest-v16.1-darwin-arm64.tar.zst", + manifestUrl: + "https://github.com/supabase/slim-services/releases/download/postgrest-v16.1/postgrest-v16.1-darwin-arm64.manifest.json", + checksumUrl: + "https://github.com/supabase/slim-services/releases/download/postgrest-v16.1/SHA256SUMS", }); - expect(release?.downloadUrl).toBe( - `https://github.com/supabase/edge-runtime/releases/download/v${edgeRuntimeVersion}/edge-runtime-v${edgeRuntimeVersion}-aarch64-darwin.tar.gz`, - ); }); - it("returns no native release for unsupported platforms", () => { + it("only exposes the three supported native targets", () => { expect( - nativeReleaseForService("auth", authVersion, { os: "win32", arch: "arm64" }), + nativeReleaseForService("auth", DEFAULT_VERSIONS.auth, { os: "win32", arch: "x64" }), ).toBeUndefined(); + expect( + nativeReleaseForService("auth", DEFAULT_VERSIONS.auth, { os: "linux", arch: "x64" })?.target, + ).toBe("linux-amd64"); }); }); describe("BinaryResolver.cachePath", () => { - it("constructs cache path", () => { + it("includes service, release provider, version, and target identity", () => { const path = BinaryResolver.cachePath("/home/user/.supabase/bin", { service: "postgres", - provider: "github.com/supabase/postgres", - version: postgresVersion, - assetName: "darwin-arm64", + releaseSet: "slim-services", + version: DEFAULT_VERSIONS.postgres, + runtime: "native", + target: "linux-amd64", }); expect(path).toBe( - `/home/user/.supabase/bin/postgres/github.com_supabase_postgres/${postgresVersion}/darwin-arm64`, - ); - }); -}); - -describe("BinaryResolver.legacyExecutablePath", () => { - it("recognizes the executable suffix used by Windows archives", () => { - expect(BinaryResolver.legacyExecutablePath("C:/cache/postgrest", "postgrest", "win32")).toBe( - "C:/cache/postgrest/postgrest.exe", - ); - }); - - it("keeps Unix executable names unchanged", () => { - expect(BinaryResolver.legacyExecutablePath("/cache/postgrest", "postgrest", "linux")).toBe( - "/cache/postgrest/postgrest", - ); - }); -}); - -describe("BinaryResolver.legacyCacheRequiredPaths", () => { - it("requires the Postgres initialization payload as well as the executable", () => { - expect(BinaryResolver.legacyCacheRequiredPaths("/cache/postgres", "postgres", "linux")).toEqual( - [ - "/cache/postgres/bin/postgres", - "/cache/postgres/bin/pg_isready", - "/cache/postgres/bin/psql", - "/cache/postgres/share/supabase-cli/bin/supabase-postgres-init.sh", - "/cache/postgres/lib", - ], + `/home/user/.supabase/bin/slim-services/postgres/${DEFAULT_VERSIONS.postgres}/native/linux-amd64`, ); }); }); diff --git a/packages/stack/src/ContainerRuntime.integration.test.ts b/packages/stack/src/ContainerRuntime.integration.test.ts new file mode 100644 index 0000000000..b2ccc51852 --- /dev/null +++ b/packages/stack/src/ContainerRuntime.integration.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Layer, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { selectStackRuntime } from "./ContainerRuntime.ts"; + +const runtimeSpawner = (availability: Readonly>) => { + const commands: string[] = []; + return { + commands, + layer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const executable = command._tag === "StandardCommand" ? command.command : ""; + commands.push(executable); + const exitCode = yield* Deferred.make(); + yield* Deferred.succeed( + exitCode, + ChildProcessSpawner.ExitCode(availability[executable] === true ? 0 : 1), + ); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exitCode), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ), + }; +}; + +describe("stack runtime selection", () => { + it.effect("uses Docker mode when the Docker daemon is usable", () => { + const spawner = runtimeSpawner({ docker: true }); + return Effect.gen(function* () { + expect(yield* selectStackRuntime()).toEqual({ + mode: "docker", + containerRuntime: "docker", + }); + expect(spawner.commands).toEqual(["docker"]); + }).pipe(Effect.provide(spawner.layer)); + }); + + it.effect("uses Podman for Docker mode when Docker is unavailable", () => { + const spawner = runtimeSpawner({ podman: true }); + return Effect.gen(function* () { + expect(yield* selectStackRuntime()).toEqual({ + mode: "docker", + containerRuntime: "podman", + }); + expect(spawner.commands).toEqual(["docker", "podman"]); + }).pipe(Effect.provide(spawner.layer)); + }); + + it.effect("uses native mode when no container runtime is usable", () => { + const spawner = runtimeSpawner({}); + return Effect.gen(function* () { + expect(yield* selectStackRuntime()).toEqual({ + mode: "native", + containerRuntime: null, + }); + }).pipe(Effect.provide(spawner.layer)); + }); + + it.effect("does not probe container runtimes when native mode is explicit", () => { + const spawner = runtimeSpawner({ docker: true }); + return Effect.gen(function* () { + expect(yield* selectStackRuntime("native")).toEqual({ + mode: "native", + containerRuntime: null, + }); + expect(spawner.commands).toEqual([]); + }).pipe(Effect.provide(spawner.layer)); + }); + + it.effect("rejects explicit Docker mode when neither runtime is usable", () => { + const spawner = runtimeSpawner({}); + return Effect.gen(function* () { + const error = yield* selectStackRuntime("docker").pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "StackBuildError", + reason: "docker_not_running", + }); + }).pipe(Effect.provide(spawner.layer)); + }); +}); diff --git a/packages/stack/src/ContainerRuntime.ts b/packages/stack/src/ContainerRuntime.ts new file mode 100644 index 0000000000..b6ca5e5fa7 --- /dev/null +++ b/packages/stack/src/ContainerRuntime.ts @@ -0,0 +1,70 @@ +import { Effect, Exit } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { StackBuildError } from "./errors.ts"; +import type { StackMode } from "./StackConfig.ts"; + +export type ContainerRuntime = "docker" | "podman"; + +export type StackRuntimeSelection = + | { readonly mode: "native"; readonly containerRuntime: null } + | { readonly mode: "docker"; readonly containerRuntime: ContainerRuntime }; + +const probeContainerRuntime = ( + runtime: ContainerRuntime, +): Effect.Effect => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const result = yield* Effect.exit( + spawner.exitCode(ChildProcess.make(runtime, ["info"])).pipe(Effect.timeout("30 seconds")), + ); + return Exit.isSuccess(result) && result.value === 0; + }); + +export const validateStackRuntime = ( + selection: StackRuntimeSelection, +): Effect.Effect< + StackRuntimeSelection, + StackBuildError, + ChildProcessSpawner.ChildProcessSpawner +> => + selection.containerRuntime === null + ? Effect.succeed(selection) + : probeContainerRuntime(selection.containerRuntime).pipe( + Effect.flatMap((usable) => + usable + ? Effect.succeed(selection) + : Effect.fail( + new StackBuildError({ + detail: `Docker mode requires a usable ${selection.containerRuntime} runtime. Restore or start the persisted ${selection.containerRuntime} runtime and retry, or delete and recreate the stack (removing its managed data) to choose another execution mode.`, + reason: "docker_not_running", + }), + ), + ), + ); + +export const selectStackRuntime = ( + requestedMode?: StackMode, +): Effect.Effect => + Effect.gen(function* () { + if (requestedMode === "native") { + return { mode: "native", containerRuntime: null }; + } + + const runtimes: ReadonlyArray = ["docker", "podman"]; + for (const runtime of runtimes) { + if (yield* probeContainerRuntime(runtime)) { + return { mode: "docker", containerRuntime: runtime }; + } + } + + if (requestedMode === "docker") { + return yield* Effect.fail( + new StackBuildError({ + detail: "Docker mode requires a usable Docker or Podman runtime", + reason: "docker_not_running", + }), + ); + } + + return { mode: "native", containerRuntime: null }; + }); diff --git a/packages/stack/src/DaemonProtocol.ts b/packages/stack/src/DaemonProtocol.ts index a0e058312e..3098e2b89e 100644 --- a/packages/stack/src/DaemonProtocol.ts +++ b/packages/stack/src/DaemonProtocol.ts @@ -5,6 +5,7 @@ const DaemonErrorCodeSchema = Schema.Literals([ "SERVICE_NOT_READY", "STACK_READINESS_TIMEOUT", "STACK_BUILD_ERROR", + "STACK_NOT_RUNNING", ]); const StackBuildReasonSchema = Schema.Literals([ @@ -38,6 +39,7 @@ export const DaemonErrorResponseSchema = Schema.Struct({ service: Schema.optionalKey(Schema.String), exitCode: Schema.optionalKey(Schema.Number), timeoutMs: Schema.optionalKey(Schema.Number), + phase: Schema.optionalKey(Schema.String), reason: Schema.optionalKey(StackBuildReasonSchema), }); diff --git a/packages/stack/src/DaemonServer.ts b/packages/stack/src/DaemonServer.ts index 0c56371133..1763f9dae2 100644 --- a/packages/stack/src/DaemonServer.ts +++ b/packages/stack/src/DaemonServer.ts @@ -11,7 +11,10 @@ import type { ControlOwnerStatus, DaemonErrorResponse } from "./DaemonProtocol.t import { FunctionsReloadConfigSchema } from "./functions.ts"; import { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts"; import { ReadyOptionsSchema } from "./StackConfig.ts"; -import { managedStackLaunchSchema, type ManagedStackLaunch } from "./managed/document.ts"; +import { + managedStackLaunchUpdateSchema, + type ManagedStackLaunchUpdate, +} from "./managed/document.ts"; // --------------------------------------------------------------------------- // Service @@ -35,7 +38,7 @@ export class DaemonServer extends Context.Service< }), options: { readonly includeOwnerRoute?: boolean; - readonly launchUpdate?: (launch: ManagedStackLaunch) => Effect.Effect; + readonly launchUpdate?: (launch: ManagedStackLaunchUpdate) => Effect.Effect; /** Supervisor-owned shutdown callbacks already stop the local stack. */ readonly stopOnShutdown?: boolean; } = {}, @@ -47,7 +50,7 @@ export class DaemonServer extends Context.Service< const server = yield* HttpServer.HttpServer; const shutdownDeferred = yield* Deferred.make(); const textEncoder = new TextEncoder(); - const errorResponse = (body: DaemonErrorResponse, status: 400 | 404 | 500) => + const errorResponse = (body: DaemonErrorResponse, status: 400 | 404 | 409 | 500) => HttpServerResponse.jsonUnsafe(body, { status }); const notFoundResponse = (name: string) => errorResponse( @@ -73,6 +76,15 @@ export class DaemonServer extends Context.Service< }, 500, ); + const notRunningResponse = (phase: string) => + errorResponse( + { + code: "STACK_NOT_RUNNING", + error: `Stack is not running (phase: ${phase})`, + phase, + }, + 409, + ); const invalidReloadPayloadResponse = () => errorResponse( { code: "STACK_BUILD_ERROR", error: "Invalid Edge Functions reload payload" }, @@ -178,8 +190,9 @@ export class DaemonServer extends Context.Service< "POST", "/managed/launch", Effect.gen(function* () { - const launch = - yield* HttpServerRequest.schemaBodyJson(managedStackLaunchSchema); + const launch = yield* HttpServerRequest.schemaBodyJson( + managedStackLaunchUpdateSchema, + ); yield* launchUpdate(launch); return HttpServerResponse.jsonUnsafe({ ok: true }); }), @@ -329,6 +342,9 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), + Effect.catchTag("StackNotRunningError", (e) => + Effect.succeed(notRunningResponse(e.phase)), + ), Effect.catchTag("StackReadinessError", (e) => terminalReadinessResponse(e.target, e.timeoutMs, e.detail), ), @@ -377,6 +393,9 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), + Effect.catchTag("StackNotRunningError", (e) => + Effect.succeed(notRunningResponse(e.phase)), + ), ), ), @@ -397,6 +416,9 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), + Effect.catchTag("StackNotRunningError", (e) => + Effect.succeed(notRunningResponse(e.phase)), + ), Effect.catchTag("StackReadinessError", (e) => terminalReadinessResponse(e.target, e.timeoutMs, e.detail), ), @@ -424,6 +446,9 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), + Effect.catchTag("StackNotRunningError", (e) => + Effect.succeed(notRunningResponse(e.phase)), + ), Effect.catchTag("StackReadinessError", (e) => terminalReadinessResponse(e.target, e.timeoutMs, e.detail), ), @@ -451,6 +476,9 @@ export class DaemonServer extends Context.Service< Effect.catchTag("StackBuildError", (e) => Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), + Effect.catchTag("StackNotRunningError", (e) => + Effect.succeed(notRunningResponse(e.phase)), + ), Effect.catchTag("StackReadinessError", (e) => terminalReadinessResponse(e.target, e.timeoutMs, e.detail), ), diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts index 8897c679b9..b3a39da814 100644 --- a/packages/stack/src/LocalStack.ts +++ b/packages/stack/src/LocalStack.ts @@ -7,11 +7,13 @@ import { Duration, Effect, Equal, + Exit, FileSystem, Layer, Path, Ref, Schema, + Scope, Semaphore, Stream, SubscriptionRef, @@ -41,8 +43,14 @@ import { StackServiceActivator, } from "./ServiceActivation.ts"; import { portFieldsForService } from "./ServicePorts.ts"; -import { StackPreparation } from "./StackPreparation.ts"; -import type { PreparedStackArtifacts } from "./StackPreparation.ts"; +import { + PreparationCompleted, + preparationClosure, + ServiceDownloadStarted, + ServiceDownloadFinished, + StackPreparation, +} from "./StackPreparation.ts"; +import type { PreparedStackArtifacts, StackPreparationInput } from "./StackPreparation.ts"; import { enabledServicesForConfig, StackBuilder, @@ -57,18 +65,13 @@ import { Stack } from "./Stack.ts"; import type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts"; import { SERVICE_NAMES, type ServiceName } from "./versions.ts"; -type LifecyclePhase = - | "idle" - | "preparing" - | "prepared" - | "starting" - | "running" - | "stopping" - | "stopped" - | "disposed"; +type LifecyclePhase = "idle" | "starting" | "running" | "stopping" | "stopped" | "disposed"; type StackService = typeof Stack.Service; +const READINESS_DIAGNOSTIC_LOG_LIMIT = 20; +const READINESS_DIAGNOSTIC_LINE_LIMIT = 512; + /** Private signal used by the Promise adapter to close its enclosing managed runtime. */ export class LocalStackLifecycle extends Context.Service< LocalStackLifecycle, @@ -123,7 +126,7 @@ const stackInfoFor = (config: ResolvedStackConfig): StackInfo => { storage: `${apiUrl}/storage/v1`, storage_s3: `${apiUrl}/storage/v1/s3`, }), - ...(config.imgproxy === false || config.startupMode === "lazy" + ...(config.imgproxy === false || config.servicePolicies.imgproxy !== "eager" ? {} : { imgproxy: `http://127.0.0.1:${config.imgproxy.port}` }), ...(config.mailpit === false @@ -182,6 +185,7 @@ export const localStackLayer = ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const scope = yield* Effect.scope; + const preparationScope = yield* Scope.fork(scope, "parallel"); const info = stackInfoFor(config); const enabledServices = enabledServicesForConfig(config); @@ -209,6 +213,29 @@ export const localStackLayer = ( : [...current, nextState]; }); + const markDownloading = (service: ServiceName) => + SubscriptionRef.update(stateRef, (current) => { + const index = current.findIndex((entry) => entry.name === service); + if (index === -1 || current[index]?.status === "Downloading") return current; + return current.map((entry, entryIndex) => + entryIndex === index + ? new StackServiceState({ ...entry, status: "Downloading" }) + : entry, + ); + }); + + const restoreStateIfDownloading = ( + service: ServiceName, + previous: StackServiceState | undefined, + ) => + previous === undefined + ? Effect.void + : SubscriptionRef.update(stateRef, (current) => { + const index = current.findIndex((entry) => entry.name === service); + if (index === -1 || current[index]?.status !== "Downloading") return current; + return current.map((entry, entryIndex) => (entryIndex === index ? previous : entry)); + }); + const syncProjectedStates = ( orchestrator: Orchestrator["Service"], serviceProjection: StackServiceProjectionCatalog, @@ -241,36 +268,141 @@ export const localStackLayer = ( return service; }); - let preparedArtifacts: PreparedStackArtifacts | undefined; - let prepareDeferred: Deferred.Deferred | undefined; + let plannedArtifacts: PreparedStackArtifacts | undefined; + let planDeferred: Deferred.Deferred | undefined; + const preparedResolutions: Partial = {}; + const preparationInFlight = new Map< + string, + Deferred.Deferred + >(); let runtimeState: RuntimeState | undefined; let runtimeDeferred: Deferred.Deferred | undefined; let exactCleanupTargets: CleanupTargets | undefined; - const ensurePrepared = Effect.suspend(() => { - if (preparedArtifacts !== undefined) { - return Effect.succeed(preparedArtifacts); - } - if (prepareDeferred !== undefined) { - return Deferred.await(prepareDeferred); - } + const preparationInput = ( + services: ReadonlyArray, + ): Effect.Effect => { + const shared = { + services, + enabledServices, + versions: versionsForConfig(config), + }; + if (config.mode === "native") return Effect.succeed({ ...shared, mode: "native" }); + return config.containerRuntime === null + ? Effect.fail( + new StackBuildError({ + detail: "Docker mode requires a selected Docker or Podman runtime", + reason: "invalid_config", + }), + ) + : Effect.succeed({ + ...shared, + mode: "docker", + containerRuntime: config.containerRuntime, + }); + }; + + const ensurePlanned = Effect.uninterruptibleMask((restore) => + Effect.suspend(() => { + if (disposed || disposing) { + return Effect.fail( + new StackBuildError({ + detail: "Cannot plan stack assets after stack disposal has begun", + }), + ); + } + if (plannedArtifacts !== undefined) return Effect.succeed(plannedArtifacts); + if (planDeferred !== undefined) return restore(Deferred.await(planDeferred)); + const deferred = Deferred.makeUnsafe(); + planDeferred = deferred; + const effect = Effect.gen(function* () { + yield* validateResolvedConfig(config); + const input = yield* preparationInput(enabledServicesForConfig(config)); + return yield* preparation.plan(input).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to plan stack assets", + cause, + reason: "asset_preparation", + }), + ), + ); + }).pipe( + Effect.tap((value) => + Effect.sync(() => { + plannedArtifacts = value; + }), + ), + Effect.ensuring(Effect.sync(() => (planDeferred = undefined))), + ); + return Effect.gen(function* () { + yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), preparationScope); + return yield* restore(Deferred.await(deferred)); + }); + }), + ); - const deferred = Deferred.makeUnsafe(); - prepareDeferred = deferred; - - const effect = Effect.gen(function* () { - yield* validateResolvedConfig(config); - yield* Ref.set(phaseRef, "preparing"); - - let prepared: PreparedStackArtifacts | undefined; - yield* preparation - .prepareEvents({ - mode: config.mode, - services: enabledServicesForConfig(config), - versions: versionsForConfig(config), - }) - .pipe( - Stream.mapError( + const prepareServices = (services: ReadonlyArray) => + Effect.uninterruptibleMask((restore) => + Effect.suspend(() => { + if (disposed || disposing) { + return Effect.fail( + new StackBuildError({ + detail: "Cannot prepare stack assets after disposal has begun", + }), + ); + } + const targets = [ + ...new Set( + services.flatMap((service) => + activationTargetsForService(enabledServices, service), + ), + ), + ]; + const preparationTargets = preparationClosure(targets, enabledServices); + const pending = preparationTargets.filter( + (service) => preparedResolutions[service] === undefined, + ); + if (pending.length === 0) { + return Effect.succeed({ + resolutions: preparedResolutions, + } satisfies PreparedStackArtifacts); + } + const key = pending.toSorted().join(","); + const existing = preparationInFlight.get(key); + if (existing !== undefined) return restore(Deferred.await(existing)); + const previousStates = new Map( + preparationTargets.flatMap((service) => { + const state = SubscriptionRef.getUnsafe(stateRef).find( + (entry) => entry.name === service, + ); + return state === undefined ? [] : [[service, state] as const]; + }), + ); + const deferred = Deferred.makeUnsafe(); + preparationInFlight.set(key, deferred); + const effect = preparationInput(pending).pipe( + Effect.flatMap((input) => + Stream.runFoldEffect( + preparation.prepareEvents(input), + () => ({ resolutions: {} }) satisfies PreparedStackArtifacts, + (current, event) => + Effect.gen(function* () { + if (event instanceof ServiceDownloadStarted) { + yield* markDownloading(event.service); + } + if (event instanceof ServiceDownloadFinished) { + yield* restoreStateIfDownloading( + event.service, + previousStates.get(event.service), + ); + } + return event instanceof PreparationCompleted ? event.artifacts : current; + }), + ), + ), + Effect.mapError( (cause) => new StackBuildError({ detail: "Failed to prepare stack assets", @@ -281,133 +413,95 @@ export const localStackLayer = ( : "asset_preparation", }), ), - ) - .pipe( - Stream.runForEach((event) => { - switch (event._tag) { - case "ServiceDownloadStarted": - return updateState( - new StackServiceState({ - name: event.service, - status: "Downloading", - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - }), - ); - case "ServiceDownloadFinished": - return updateState( - new StackServiceState({ - name: event.service, - status: "Pending", - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - }), - ); - case "PreparationCompleted": - return Effect.sync(() => { - prepared = event.artifacts; - }); - } - }), + Effect.tapError(() => + Effect.forEach( + preparationTargets, + (service) => { + return restoreStateIfDownloading(service, previousStates.get(service)); + }, + { discard: true, concurrency: "unbounded" }, + ), + ), + Effect.tap((value) => + Effect.sync(() => Object.assign(preparedResolutions, value.resolutions)), + ), + Effect.ensuring(Effect.sync(() => preparationInFlight.delete(key))), ); + return Effect.gen(function* () { + yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), preparationScope); + return yield* restore(Deferred.await(deferred)); + }); + }), + ); - if (prepared === undefined) { - return yield* Effect.fail( + const ensureRuntime = Effect.uninterruptibleMask((restore) => + Effect.suspend(() => { + if (disposed || disposing) { + return Effect.fail( new StackBuildError({ - detail: "Stack preparation completed without prepared artifacts", + detail: "Cannot ensure stack runtime after stack disposal has begun", }), ); } + if (runtimeState !== undefined) { + return Effect.succeed(runtimeState); + } + if (runtimeDeferred !== undefined) return restore(Deferred.await(runtimeDeferred)); - yield* Ref.set(phaseRef, "prepared"); - return prepared; - }).pipe( - Effect.tap((value) => - Effect.sync(() => { - preparedArtifacts = value; - }), - ), - Effect.onError(() => Ref.set(phaseRef, "idle")), - Effect.ensuring( - Effect.sync(() => { - prepareDeferred = undefined; - }), - ), - ); + const deferred = Deferred.makeUnsafe(); + runtimeDeferred = deferred; - return Effect.gen(function* () { - yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), scope); - return yield* Deferred.await(deferred); - }); - }); - - const ensureRuntime = Effect.suspend(() => { - if (runtimeState !== undefined) { - return Effect.succeed(runtimeState); - } - if (runtimeDeferred !== undefined) { - return Deferred.await(runtimeDeferred); - } + const effect = Effect.gen(function* () { + const prepared = yield* ensurePlanned; + const { graph, serviceProjection, cleanupTargets } = yield* builder.build( + config, + prepared, + ); + exactCleanupTargets = cleanupTargets; - const deferred = Deferred.makeUnsafe(); - runtimeDeferred = deferred; + const orchLayer = Orchestrator.layer(graph).pipe( + Layer.provide(Layer.succeed(LogBuffer, logBuffer)), + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + ); + const orchServices = yield* Layer.buildWithScope(orchLayer, scope); + const orchestrator = Context.get(orchServices, Orchestrator); - const effect = Effect.gen(function* () { - const prepared = yield* ensurePrepared; - const { graph, serviceProjection, cleanupTargets } = yield* builder.build( - config, - prepared, - ); - exactCleanupTargets = cleanupTargets; + yield* syncProjectedStates(orchestrator, serviceProjection); + yield* orchestrator.allStateChanges().pipe( + Stream.runForEach(() => syncProjectedStates(orchestrator, serviceProjection)), + Effect.ignore, + Effect.forkIn(scope), + ); - const orchLayer = Orchestrator.layer(graph).pipe( - Layer.provide(Layer.succeed(LogBuffer, logBuffer)), - Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), - ); - const orchServices = yield* Layer.buildWithScope(orchLayer, scope); - const orchestrator = Context.get(orchServices, Orchestrator); - - yield* syncProjectedStates(orchestrator, serviceProjection); - yield* orchestrator.allStateChanges().pipe( - Stream.runForEach(() => syncProjectedStates(orchestrator, serviceProjection)), - Effect.ignore, - Effect.forkIn(scope), + return { + orchestrator, + graph, + serviceProjection, + } satisfies RuntimeState; + }).pipe( + Effect.tap((value) => + Effect.sync(() => { + runtimeState = value; + }), + ), + Effect.ensuring( + Effect.sync(() => { + runtimeDeferred = undefined; + }), + ), ); - return { - orchestrator, - graph, - serviceProjection, - } satisfies RuntimeState; - }).pipe( - Effect.tap((value) => - Effect.sync(() => { - runtimeState = value; - }), - ), - Effect.ensuring( - Effect.sync(() => { - runtimeDeferred = undefined; - }), - ), - ); - - return Effect.gen(function* () { - yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), scope); - return yield* Deferred.await(deferred); - }); - }); + return Effect.gen(function* () { + yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), preparationScope); + return yield* restore(Deferred.await(deferred)); + }); + }), + ); let disposed = false; let disposing = false; const runtimeHost = Effect.gen(function* () { - const prepared = yield* ensurePrepared; + const prepared = yield* ensurePlanned; const platform = yield* detectPlatform; const edgeRuntimeResolution = prepared.resolutions["edge-runtime"]; return { @@ -485,7 +579,11 @@ export const localStackLayer = ( const syncRuntimeProjectedStates = (runtime: RuntimeState) => syncProjectedStates(runtime.orchestrator, runtime.serviceProjection); const serviceStartOptions = { - beforeStart: (name: string) => portLease.reserve(portFieldsForService(name)), + // Reservation may yield while disposal flips the lifecycle state. + beforeStart: (name: string) => + portLease + .reserve(portFieldsForService(name)) + .pipe(Effect.andThen(requireMutable(`start service ${name}`))), beforeSpawn: (name: string) => portLease.release(portFieldsForService(name)), }; const knownServiceError = (service: string, cause: ServiceNotFoundError) => @@ -605,12 +703,27 @@ export const localStackLayer = ( const disposeOnce = () => Effect.suspend(() => { disposing = true; - return Effect.gen(function* () { + const preparationError = new StackBuildError({ + detail: "Stack disposed during asset preparation", + }); + const failInFlight = Effect.gen(function* () { + if (planDeferred !== undefined) { + yield* Deferred.fail(planDeferred, preparationError); + } + for (const deferred of preparationInFlight.values()) { + yield* Deferred.fail(deferred, preparationError); + } + if (runtimeDeferred !== undefined) { + yield* Deferred.fail(runtimeDeferred, preparationError); + } + }); + const cleanup = Effect.gen(function* () { if (disposed) { return; } disposed = true; yield* Ref.set(phaseRef, "stopping"); + yield* Scope.close(preparationScope, Exit.void); yield* cleanupLocalStackResources({ stop: () => runtimeState === undefined ? Effect.void : runtimeState.orchestrator.stop(), @@ -622,6 +735,7 @@ export const localStackLayer = ( Effect.ensuring(Ref.set(phaseRef, "disposed")), ); }).pipe(withLifecycleLock); + return failInFlight.pipe(Effect.andThen(cleanup)); }).pipe( Effect.ensuring(Deferred.succeed(disposedSignal, undefined).pipe(Effect.asVoid)), Effect.uninterruptible, @@ -653,18 +767,67 @@ export const localStackLayer = ( }), ); }; + const readinessErrorWithDiagnostics = ( + error: StackReadinessError, + ): Effect.Effect => + Effect.gen(function* () { + if (runtimeState === undefined) return error; + + const [states, logs] = yield* Effect.all([ + runtimeState.orchestrator.getAllStates(), + logBuffer.historyAll(READINESS_DIAGNOSTIC_LOG_LIMIT), + ]); + const nonReadyStates = states.filter( + (state) => + state.status !== "Healthy" && !(state.status === "Stopped" && state.exitCode === 0), + ); + const stateDetail = + nonReadyStates.length === 0 + ? "none" + : nonReadyStates + .map((state) => { + const errorDetail = + state.error === null + ? "" + : `, error=${state.error.slice(0, READINESS_DIAGNOSTIC_LINE_LIMIT)}`; + return `${state.name}: ${state.status} (desired=${state.desired}, restarts=${state.restartCount}${errorDetail})`; + }) + .join("; "); + const logDetail = + logs.length === 0 + ? "none" + : logs + .map( + (entry) => + `[${entry.service}/${entry.stream}] ${entry.line.slice(0, READINESS_DIAGNOSTIC_LINE_LIMIT)}`, + ) + .join("\n"); + + return new StackReadinessError({ + target: error.target, + timeoutMs: error.timeoutMs, + detail: `${error.detail}\nNon-ready services: ${stateDetail}\nRecent logs:\n${logDetail}`, + }); + }).pipe(Effect.catchCause(() => Effect.succeed(error))); const cleanupOnReadinessFailure = ( effect: Effect.Effect, ): Effect.Effect => effect.pipe( - Effect.catchTag("StackReadinessError", (error) => - disposeOnce().pipe(Effect.andThen(Effect.fail(error))), + Effect.catchIf( + (error): error is StackReadinessError => error instanceof StackReadinessError, + (error) => + readinessErrorWithDiagnostics(error).pipe( + Effect.flatMap((diagnosticError) => + disposeOnce().pipe(Effect.andThen(Effect.fail(diagnosticError))), + ), + ), ), ); yield* Effect.addFinalizer(disposeOnce); const activateService = (name: ServiceName) => Effect.gen(function* () { + yield* requireMutable(`activate service ${name}`); yield* requireRunningPhase; const service = yield* requireKnownServiceName(name); const existing = yield* inspectStartedTargets(service); @@ -684,6 +847,7 @@ export const localStackLayer = ( ); return; } + yield* prepareServices([service]); const started = yield* Effect.gen(function* () { yield* requireRunningPhase; const concurrentlyStarted = yield* inspectStartedTargets(service); @@ -708,13 +872,17 @@ export const localStackLayer = ( yield* Ref.set(phaseRef, "starting"); const runtime = yield* ensureRuntime; yield* configureFunctions(config, yield* Ref.get(functionsBundleRef)); - serviceStartupBegan = true; - if (config.startupMode === "lazy") { + const eager = eagerServices(enabledServices, config.servicePolicies); + const allServicesEager = eager.length === enabledServices.length; + if (!allServicesEager) { const readiness: Array> = []; + yield* prepareServices(["postgres", ...eager]); + yield* requireMutable("start"); if ( runtime.graph.startOrder.some((definition) => definition.name === "postgres-init") ) { + serviceStartupBegan = true; yield* runtime.orchestrator .startService("postgres-init", serviceStartOptions) .pipe( @@ -732,27 +900,35 @@ export const localStackLayer = ( ), ); } - for (const service of eagerServices(enabledServices)) { + for (const service of eager) { + yield* requireMutable("start"); + serviceStartupBegan = true; const started = yield* beginStartTargets( service, - new Set(lifecycleTargetsForService(enabledServices, service)), + new Set(activationTargetsForService(enabledServices, service)), ); readiness.push(waitForTargets(started)); } yield* Effect.all(readiness, { concurrency: "unbounded", discard: true }).pipe( (effect) => withReadinessPolicy(effect, "stack"), ); + yield* syncRuntimeProjectedStates(runtime); } else { + yield* prepareServices(enabledServices); + yield* requireMutable("start"); + serviceStartupBegan = true; yield* runtime.orchestrator.start(serviceStartOptions); yield* runtime.orchestrator .waitAllReady() .pipe((effect) => withReadinessPolicy(effect, "stack")); yield* syncRuntimeProjectedStates(runtime); } + yield* requireMutable("start"); yield* Ref.set(phaseRef, "running"); }).pipe( Effect.onError(() => Ref.set(phaseRef, "stopped")), withLifecycleLock, + cleanupOnReadinessFailure, Effect.onError(() => (serviceStartupBegan ? disposeOnce() : Effect.void)), ); }, @@ -772,9 +948,13 @@ export const localStackLayer = ( dispose: disposeOnce, startService: (name) => Effect.gen(function* () { + yield* requireMutable(`start service ${name}`); + yield* requireRunningPhase; + const service = yield* requireKnownServiceName(name); + yield* prepareServices([service]); const started = yield* Effect.gen(function* () { yield* requireMutable(`start service ${name}`); - const service = yield* requireKnownServiceName(name); + yield* requireRunningPhase; return yield* beginStartTargets( service, new Set(lifecycleTargetsForService(enabledServices, service)), @@ -785,6 +965,7 @@ export const localStackLayer = ( stopService: (name) => Effect.gen(function* () { yield* requireMutable(`stop service ${name}`); + yield* requireRunningPhase; const service = yield* requireKnownServiceName(name); const runtime = yield* ensureRuntime; for (const target of lifecycleTargetsForService( @@ -799,9 +980,13 @@ export const localStackLayer = ( }).pipe(withLifecycleLock), restartService: (name) => Effect.gen(function* () { + yield* requireMutable(`restart service ${name}`); + yield* requireRunningPhase; + const service = yield* requireKnownServiceName(name); + yield* prepareServices([service]); const started = yield* Effect.gen(function* () { yield* requireMutable(`restart service ${name}`); - const service = yield* requireKnownServiceName(name); + yield* requireRunningPhase; const runtime = yield* ensureRuntime; yield* runtime.orchestrator.restartService(service, serviceStartOptions); return { runtime, targets: [service] }; @@ -810,14 +995,19 @@ export const localStackLayer = ( }).pipe(cleanupOnReadinessFailure), reloadFunctions: (opts) => Effect.gen(function* () { + yield* requireMutable("reload functions"); + yield* requireRunningPhase; + yield* requireKnownService("edge-runtime"); + const requestedBundle = + opts?.functions === undefined + ? undefined + : yield* decodeFunctionsBundle(opts.functions); + yield* prepareServices(["edge-runtime"]); const started = yield* Effect.gen(function* () { yield* requireMutable("reload functions"); - yield* requireKnownService("edge-runtime"); + yield* requireRunningPhase; const currentBundle = yield* Ref.get(functionsBundleRef); - const nextBundle = - opts?.functions === undefined - ? currentBundle - : yield* decodeFunctionsBundle(opts.functions); + const nextBundle = requestedBundle ?? currentBundle; yield* configureFunctions(config, nextBundle); yield* Ref.set(functionsBundleRef, nextBundle); const runtime = yield* ensureRuntime; @@ -834,16 +1024,24 @@ export const localStackLayer = ( }).pipe(cleanupOnReadinessFailure), reloadEdgeRuntime: (opts) => Effect.gen(function* () { + yield* requireMutable("reload Edge Runtime"); + yield* requireRunningPhase; + yield* requireKnownService("edge-runtime"); + if (opts.edgeRuntime.enabled === false) { + return yield* Effect.fail(new ServiceNotFoundError({ name: "edge-runtime" })); + } + const requestedBundle = + opts.functions === undefined + ? undefined + : yield* decodeFunctionsBundle(opts.functions); + yield* prepareServices(["edge-runtime"]); const started = yield* Effect.gen(function* () { yield* requireMutable("reload Edge Runtime"); - yield* requireKnownService("edge-runtime"); + yield* requireRunningPhase; const nextConfig = yield* configWithEdgeRuntimeOptions(opts); const currentBundle = yield* Ref.get(functionsBundleRef); - const nextBundle = - opts.functions === undefined - ? currentBundle - : yield* decodeFunctionsBundle(opts.functions); - const prepared = yield* ensurePrepared; + const nextBundle = requestedBundle ?? currentBundle; + const prepared = yield* ensurePlanned; const runtime = yield* ensureRuntime; const buildResult = yield* builder.build(nextConfig, prepared); const edgeRuntimeDef = buildResult.graph.startOrder.find( diff --git a/packages/stack/src/Platform.ts b/packages/stack/src/Platform.ts index 5ed0e87e5e..cf819bf33c 100644 --- a/packages/stack/src/Platform.ts +++ b/packages/stack/src/Platform.ts @@ -5,40 +5,22 @@ export interface PlatformInfo { readonly arch: string; } +/** Native slim-service release targets. The release set intentionally has no + * windows or x64 macOS artifacts. */ +export type NativeTarget = "darwin-arm64" | "linux-amd64" | "linux-arm64"; + +export const nativeTargetForPlatform = (platform: PlatformInfo): NativeTarget | undefined => { + if (platform.os === "darwin" && platform.arch === "arm64") return "darwin-arm64"; + if (platform.os === "linux" && platform.arch === "x64") return "linux-amd64"; + if (platform.os === "linux" && platform.arch === "arm64") return "linux-arm64"; + return undefined; +}; + export const detectPlatform: Effect.Effect = Effect.sync(() => ({ os: process.platform, arch: process.arch, })); -export const postgresAssetName = (p: PlatformInfo): string | null => { - if (p.os === "darwin" && p.arch === "arm64") return "darwin-arm64"; - if (p.os === "linux" && p.arch === "x64") return "linux-x64"; - if (p.os === "linux" && p.arch === "arm64") return "linux-arm64"; - return null; -}; - -export const postgrestAssetName = (p: PlatformInfo): string | null => { - if (p.os === "darwin" && p.arch === "arm64") return "macos-aarch64"; - if (p.os === "linux" && p.arch === "x64") return "linux-static-x86-64"; - if (p.os === "linux" && p.arch === "arm64") return "ubuntu-aarch64"; - if (p.os === "win32" && p.arch === "x64") return "windows-x86-64"; - return null; -}; - -export const authAssetName = (p: PlatformInfo): string | null => { - if (p.os === "darwin" && p.arch === "arm64") return "darwin-arm64"; - if (p.os === "linux" && p.arch === "x64") return "x86"; - if (p.os === "linux" && p.arch === "arm64") return "arm64"; - return null; -}; - -export const edgeRuntimeAssetName = (p: PlatformInfo): string | null => { - if (p.os === "darwin" && p.arch === "arm64") return "aarch64-darwin"; - if (p.os === "linux" && p.arch === "x64") return "x86_64-linux"; - if (p.os === "linux" && p.arch === "arm64") return "aarch64-linux"; - return null; -}; - /** Host address that Docker containers should use to reach services on the host machine. */ export const dockerHostAddress = (_os: string): string => "host.docker.internal"; diff --git a/packages/stack/src/Platform.unit.test.ts b/packages/stack/src/Platform.unit.test.ts index 07a10d2049..67a8095557 100644 --- a/packages/stack/src/Platform.unit.test.ts +++ b/packages/stack/src/Platform.unit.test.ts @@ -4,10 +4,7 @@ import { detectPlatform, dockerHostAddress, dockerNetworkArgs, - postgresAssetName, - postgrestAssetName, - authAssetName, - edgeRuntimeAssetName, + nativeTargetForPlatform, } from "./Platform.ts"; describe("detectPlatform", () => { @@ -22,79 +19,21 @@ describe("detectPlatform", () => { ); }); -describe("postgresAssetName", () => { +describe("nativeTargetForPlatform", () => { it("maps darwin-arm64", () => { - expect(postgresAssetName({ os: "darwin", arch: "arm64" })).toBe("darwin-arm64"); + expect(nativeTargetForPlatform({ os: "darwin", arch: "arm64" })).toBe("darwin-arm64"); }); it("maps linux-x64", () => { - expect(postgresAssetName({ os: "linux", arch: "x64" })).toBe("linux-x64"); + expect(nativeTargetForPlatform({ os: "linux", arch: "x64" })).toBe("linux-amd64"); }); it("maps linux-arm64", () => { - expect(postgresAssetName({ os: "linux", arch: "arm64" })).toBe("linux-arm64"); + expect(nativeTargetForPlatform({ os: "linux", arch: "arm64" })).toBe("linux-arm64"); }); it("returns null for unsupported", () => { - expect(postgresAssetName({ os: "win32", arch: "x64" })).toBeNull(); - }); -}); - -describe("postgrestAssetName", () => { - it("maps darwin-arm64 to macos-aarch64", () => { - expect(postgrestAssetName({ os: "darwin", arch: "arm64" })).toBe("macos-aarch64"); - }); - - it("maps linux-x64 to linux-static-x86-64", () => { - expect(postgrestAssetName({ os: "linux", arch: "x64" })).toBe("linux-static-x86-64"); - }); - - it("maps linux-arm64 to ubuntu-aarch64", () => { - expect(postgrestAssetName({ os: "linux", arch: "arm64" })).toBe("ubuntu-aarch64"); - }); - - it("maps win32-x64 to windows-x86-64", () => { - expect(postgrestAssetName({ os: "win32", arch: "x64" })).toBe("windows-x86-64"); - }); - - it("returns null for unsupported", () => { - expect(postgrestAssetName({ os: "win32", arch: "arm64" })).toBeNull(); - }); -}); - -describe("authAssetName", () => { - it("maps darwin-arm64 to darwin-arm64", () => { - expect(authAssetName({ os: "darwin", arch: "arm64" })).toBe("darwin-arm64"); - }); - - it("maps linux-x64 to x86", () => { - expect(authAssetName({ os: "linux", arch: "x64" })).toBe("x86"); - }); - - it("maps linux-arm64 to arm64", () => { - expect(authAssetName({ os: "linux", arch: "arm64" })).toBe("arm64"); - }); - - it("returns null for unsupported", () => { - expect(authAssetName({ os: "darwin", arch: "x64" })).toBeNull(); - }); -}); - -describe("edgeRuntimeAssetName", () => { - it("maps darwin-arm64 to aarch64-darwin", () => { - expect(edgeRuntimeAssetName({ os: "darwin", arch: "arm64" })).toBe("aarch64-darwin"); - }); - - it("maps linux-x64 to x86_64-linux", () => { - expect(edgeRuntimeAssetName({ os: "linux", arch: "x64" })).toBe("x86_64-linux"); - }); - - it("maps linux-arm64 to aarch64-linux", () => { - expect(edgeRuntimeAssetName({ os: "linux", arch: "arm64" })).toBe("aarch64-linux"); - }); - - it("returns null for unsupported", () => { - expect(edgeRuntimeAssetName({ os: "win32", arch: "x64" })).toBeNull(); + expect(nativeTargetForPlatform({ os: "win32", arch: "x64" })).toBeUndefined(); }); }); diff --git a/packages/stack/src/PortAllocator.integration.test.ts b/packages/stack/src/PortAllocator.integration.test.ts index 7703fe9498..85063fa5ad 100644 --- a/packages/stack/src/PortAllocator.integration.test.ts +++ b/packages/stack/src/PortAllocator.integration.test.ts @@ -53,12 +53,12 @@ describe("selected-field port allocation", () => { expect(unavailable._tag).toBe("Failure"); await Effect.runPromise(lease.release(["apiPort"])); - const rebound = await Effect.runPromise( + const unavailableWhileLeaseIsActive = await Effect.runPromise( allocatePortSet([ { field: "apiPort", selection: { kind: "exact", port: lease.ports.apiPort! } }, - ]), + ]).pipe(Effect.exit), ); - expect(rebound.apiPort).toBe(lease.ports.apiPort); + expect(unavailableWhileLeaseIsActive._tag).toBe("Failure"); await Effect.runPromise(lease.reserve(["apiPort"])); const unavailableAgain = await Effect.runPromise( diff --git a/packages/stack/src/PortAllocator.ts b/packages/stack/src/PortAllocator.ts index 6eb7b3ff58..f8a2bfc06f 100644 --- a/packages/stack/src/PortAllocator.ts +++ b/packages/stack/src/PortAllocator.ts @@ -1,4 +1,8 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, open, readFile, readdir, rm, stat } from "node:fs/promises"; import { createServer, type Server } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { Data, Effect, Schema, Semaphore } from "effect"; import { PortSetSchema, type PortField, type PortSet } from "./PortCatalog.ts"; @@ -108,6 +112,155 @@ interface BoundPort { readonly server: Server; } +interface PortClaim { + readonly path: string; + readonly port: number; + readonly token: string; +} + +interface ClaimRecord { + readonly pid: number; + readonly token: string; +} + +const claimNamespace = (): string => { + const uid = process.getuid?.(); + if (uid !== undefined) return `uid-${uid}`; + const username = process.env.USER ?? process.env.USERNAME ?? "unknown"; + const safeUsername = username.replace(/[^a-zA-Z0-9._-]/g, "_") || "unknown"; + return `user-${safeUsername}`; +}; + +const CLAIM_ROOT = join(tmpdir(), `supabase-stack-port-claims-${claimNamespace()}`); +const CLAIM_STALE_AFTER_MS = 30_000; + +const claimPath = (port: number): string => join(CLAIM_ROOT, `port-${port}`); + +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (cause) { + return typeof cause === "object" && cause !== null && "code" in cause + ? Reflect.get(cause, "code") !== "ESRCH" + : false; + } +}; + +const readClaimRecord = async (path: string): Promise => { + let contents: string; + try { + contents = await readFile(path, "utf8"); + } catch (cause) { + if (typeof cause === "object" && cause !== null && "code" in cause) { + if (Reflect.get(cause, "code") === "ENOENT") return undefined; + } + throw cause; + } + try { + const value: unknown = JSON.parse(contents); + if (typeof value !== "object" || value === null) return undefined; + const pid = Reflect.get(value, "pid"); + const token = Reflect.get(value, "token"); + return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && typeof token === "string" + ? { pid, token } + : undefined; + } catch { + return undefined; + } +}; + +const claimIsStale = async (path: string): Promise => { + const record = await readClaimRecord(path); + if (record !== undefined) return !isProcessAlive(record.pid); + try { + const info = await stat(path); + return Date.now() - info.mtimeMs > CLAIM_STALE_AFTER_MS; + } catch (cause) { + if (typeof cause === "object" && cause !== null && "code" in cause) { + if (Reflect.get(cause, "code") === "ENOENT") return true; + } + throw cause; + } +}; + +const acquirePortClaim = (port: number): Effect.Effect => + Effect.tryPromise({ + try: async () => { + await mkdir(CLAIM_ROOT, { recursive: true }); + const path = claimPath(port); + const token = randomUUID(); + const contents = JSON.stringify({ pid: process.pid, token }); + + while (true) { + try { + const handle = await open(path, "wx"); + try { + await handle.writeFile(contents, "utf8"); + } finally { + await handle.close(); + } + return { path, port, token }; + } catch (cause) { + if ( + typeof cause !== "object" || + cause === null || + !("code" in cause) || + Reflect.get(cause, "code") !== "EEXIST" + ) { + throw cause; + } + if (!(await claimIsStale(path))) { + throw new PortAllocationError({ detail: `Port ${port} is not available`, port }); + } + await rm(path, { force: true }); + } + } + }, + catch: (cause) => + cause instanceof PortAllocationError + ? cause + : new PortAllocationError({ detail: `Failed to claim port ${port}`, cause, port }), + }); + +const releasePortClaim = (claim: PortClaim): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const record = await readClaimRecord(claim.path); + if (record?.token !== claim.token || record.pid !== process.pid) return; + await rm(claim.path, { force: true }); + }, + catch: () => undefined, + }).pipe( + Effect.catch(() => Effect.void), + Effect.asVoid, + ); + +const claimedPorts = (): Effect.Effect> => + Effect.tryPromise({ + try: async () => { + const entries = await readdir(CLAIM_ROOT, { withFileTypes: true }).catch((cause) => { + if (typeof cause === "object" && cause !== null && "code" in cause) { + if (Reflect.get(cause, "code") === "ENOENT") return []; + } + throw cause; + }); + const ports = new Set(); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.startsWith("port-")) continue; + const port = Number(entry.name.slice("port-".length)); + if (!Number.isInteger(port) || port <= 0 || port > 65_535) continue; + if (await claimIsStale(join(CLAIM_ROOT, entry.name))) { + await rm(join(CLAIM_ROOT, entry.name), { force: true }); + continue; + } + ports.add(port); + } + return ports; + }, + catch: () => new Set(), + }).pipe(Effect.catch(() => Effect.succeed(new Set()))); + const bindPort = (port: number): Effect.Effect => Effect.callback((resume) => { const server = createServer((socket) => socket.destroy()); @@ -144,7 +297,9 @@ const bindPort = (port: number): Effect.Effect = export interface PortLease { readonly ports: PortSet; readonly reserve: (fields: ReadonlyArray) => Effect.Effect; + /** Releases TCP reservations while retaining ownership claims for this lease. */ readonly release: (fields: ReadonlyArray) => Effect.Effect; + /** Releases all TCP reservations and ends ownership of every selected port. */ readonly releaseAll: Effect.Effect; } @@ -183,13 +338,53 @@ const releaseReservations = ( { discard: true }, ); +const releaseClaims = ( + claims: Map, + fields: ReadonlyArray, +): Effect.Effect => + Effect.forEach( + uniquePortFields(fields), + (field) => { + const claim = claims.get(field); + if (claim === undefined) return Effect.void; + claims.delete(field); + return releasePortClaim(claim); + }, + { discard: true }, + ); + +const claimAndBind = ( + field: PortField, + port: number, + claims: Map, +): Effect.Effect => { + const existingClaim = claims.get(field); + return ( + existingClaim === undefined ? acquirePortClaim(port) : Effect.succeed(existingClaim) + ).pipe( + Effect.flatMap((claim) => + bindPort(port).pipe( + Effect.onError(() => (existingClaim === undefined ? releasePortClaim(claim) : Effect.void)), + Effect.tap(({ server }) => + Effect.sync(() => { + claims.set(field, claim); + return server; + }), + ), + ), + ), + ); +}; + const reserveReservations = ( ports: PortSet, reservations: Map, + claims: Map, fields: ReadonlyArray, ): Effect.Effect => Effect.suspend(() => { const acquired: Array = []; + const acquiredClaims: Array = []; return Effect.forEach( uniquePortFields(fields), (field) => { @@ -203,39 +398,70 @@ const reserveReservations = ( }), ); } - return bindPort(port).pipe( + const existingClaim = claims.has(field); + return claimAndBind(field, port, claims).pipe( Effect.mapError((error) => withPortField(field, error)), Effect.tap(({ server }) => Effect.sync(() => { reservations.set(field, server); acquired.push(field); + if (!existingClaim) acquiredClaims.push(field); }), ), ); }, { discard: true }, - ).pipe(Effect.onError(() => releaseReservations(reservations, acquired))); + ).pipe( + Effect.onError(() => + Effect.all( + [releaseReservations(reservations, acquired), releaseClaims(claims, acquiredClaims)], + { discard: true }, + ), + ), + ); }); -const makePortLease = (ports: PortSet, reservations: Map): PortLease => { +const makePortLease = ( + ports: PortSet, + reservations: Map, + claims: Map, +): PortLease => { const lock = Semaphore.makeUnsafe(1); return { ports, - reserve: (fields) => lock.withPermit(reserveReservations(ports, reservations, fields)), + reserve: (fields) => lock.withPermit(reserveReservations(ports, reservations, claims, fields)), release: (fields) => lock.withPermit(releaseReservations(reservations, fields)), releaseAll: lock.withPermit( - Effect.suspend(() => releaseReservations(reservations, [...reservations.keys()])), + Effect.suspend(() => + Effect.all( + [ + releaseReservations(reservations, [...reservations.keys()]), + releaseClaims(claims, [...claims.keys()]), + ], + { discard: true }, + ), + ), ), }; }; const reserveRandomPort = ( exclude: ReadonlySet, + field: PortField, + claims: Map, ): Effect.Effect => Effect.flatMap(bindPort(0), (bound) => exclude.has(bound.port) - ? closeServer(bound.server).pipe(Effect.andThen(reserveRandomPort(exclude))) - : Effect.succeed(bound), + ? closeServer(bound.server).pipe(Effect.andThen(reserveRandomPort(exclude, field, claims))) + : acquirePortClaim(bound.port).pipe( + Effect.tap((claim) => Effect.sync(() => claims.set(field, claim))), + Effect.map(() => bound), + Effect.catchTag("PortAllocationError", () => + closeServer(bound.server).pipe( + Effect.andThen(reserveRandomPort(exclude, field, claims)), + ), + ), + ), ); const resolveSelection = ( @@ -264,7 +490,7 @@ export const allocatePortSet = ( options: PortAllocationOptions = {}, ): Effect.Effect => Effect.gen(function* () { - const reserved = options.reserved ?? new Set(); + const reserved = new Set([...(options.reserved ?? []), ...(yield* claimedPorts())]); const probe = options.probe ?? defaultPortProbe; const allocated = new Set(); const partial: Partial> = {}; @@ -287,6 +513,7 @@ export const reservePortSet = ( ): Effect.Effect => Effect.suspend(() => { const reservations = new Map(); + const claims = new Map(); const reserve = Effect.gen(function* () { const reserved = options.reserved ?? new Set(); const allocated = new Set(); @@ -320,26 +547,43 @@ export const reservePortSet = ( port: selection.port, }); } - bound = yield* bindAndRegister(request.field, bindPort(selection.port)); + bound = yield* bindAndRegister( + request.field, + claimAndBind(request.field, selection.port, claims), + ); } else if (selection.preferred !== undefined && !exclude.has(selection.preferred)) { + const preferred = selection.preferred; bound = yield* bindAndRegister( request.field, - bindPort(selection.preferred).pipe( - Effect.catchTag("PortAllocationError", () => reserveRandomPort(exclude)), + claimAndBind(request.field, preferred, claims).pipe( + Effect.catchTag("PortAllocationError", () => + reserveRandomPort(exclude, request.field, claims), + ), ), ); } else { - bound = yield* bindAndRegister(request.field, reserveRandomPort(exclude)); + bound = yield* bindAndRegister( + request.field, + reserveRandomPort(exclude, request.field, claims), + ); } allocated.add(bound.port); partial[request.field] = bound.port; } - return makePortLease(Schema.decodeUnknownSync(PortSetSchema)(partial), reservations); + return makePortLease(Schema.decodeUnknownSync(PortSetSchema)(partial), reservations, claims); }); return reserve.pipe( - Effect.onError(() => releaseReservations(reservations, [...reservations.keys()])), + Effect.onError(() => + Effect.all( + [ + releaseReservations(reservations, [...reservations.keys()]), + releaseClaims(claims, [...claims.keys()]), + ], + { discard: true }, + ), + ), ); }); diff --git a/packages/stack/src/RemoteStack.integration.test.ts b/packages/stack/src/RemoteStack.integration.test.ts index 548eaf95fe..f665ba2a69 100644 --- a/packages/stack/src/RemoteStack.integration.test.ts +++ b/packages/stack/src/RemoteStack.integration.test.ts @@ -4,7 +4,7 @@ import { Cause, Effect, Exit, Fiber, Layer, ManagedRuntime, Result, Stream } fro import * as http from "node:http"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { DaemonServer } from "./DaemonServer.ts"; -import { StackBuildError, StackReadinessError } from "./errors.ts"; +import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts"; import type { FunctionsReloadConfig, ResolvedFunctionsBundle } from "./functions.ts"; import { RemoteStack } from "./RemoteStack.ts"; import { Stack, type EdgeRuntimeReloadConfig, type StackInfo } from "./Stack.ts"; @@ -85,6 +85,7 @@ function mockStack( readonly waitReadyBuildReason?: "invalid_config" | "docker_not_running" | "asset_preparation"; readonly waitReadyTimeoutMs?: number; readonly restartServiceReadyError?: string; + readonly notRunningPhase?: string; } = {}, ) { let stopped = false; @@ -107,54 +108,64 @@ function mockStack( startService: (name: string) => name === "unknown" ? Effect.fail(new ServiceNotFoundError({ name })) - : options.startServiceBuildError !== undefined - ? Effect.fail( - new StackBuildError({ - detail: options.startServiceBuildError, - ...(options.startServiceBuildReason === undefined - ? {} - : { reason: options.startServiceBuildReason }), - }), - ) - : options.startServiceReadyError !== undefined + : options.notRunningPhase !== undefined + ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase })) + : options.startServiceBuildError !== undefined ? Effect.fail( - new ServiceReadyError({ - name, - reason: options.startServiceReadyError, + new StackBuildError({ + detail: options.startServiceBuildError, + ...(options.startServiceBuildReason === undefined + ? {} + : { reason: options.startServiceBuildReason }), }), ) - : Effect.sync(() => { - serviceCalls.push(`start:${name}`); - }), + : options.startServiceReadyError !== undefined + ? Effect.fail( + new ServiceReadyError({ + name, + reason: options.startServiceReadyError, + }), + ) + : Effect.sync(() => { + serviceCalls.push(`start:${name}`); + }), stopService: (name: string) => name === "unknown" ? Effect.fail(new ServiceNotFoundError({ name })) - : Effect.sync(() => { - serviceCalls.push(`stop:${name}`); - }), + : options.notRunningPhase !== undefined + ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase })) + : Effect.sync(() => { + serviceCalls.push(`stop:${name}`); + }), restartService: (name: string) => name === "unknown" ? Effect.fail(new ServiceNotFoundError({ name })) - : options.restartServiceReadyError !== undefined - ? Effect.fail( - new ServiceReadyError({ - name, - reason: options.restartServiceReadyError, + : options.notRunningPhase !== undefined + ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase })) + : options.restartServiceReadyError !== undefined + ? Effect.fail( + new ServiceReadyError({ + name, + reason: options.restartServiceReadyError, + }), + ) + : Effect.sync(() => { + serviceCalls.push(`restart:${name}`); }), - ) - : Effect.sync(() => { - serviceCalls.push(`restart:${name}`); - }), reloadFunctions: (config) => - Effect.sync(() => { - functionReloads.push(config ?? {}); - serviceCalls.push("reload-functions"); - }), + options.notRunningPhase !== undefined + ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase })) + : Effect.sync(() => { + functionReloads.push(config ?? {}); + serviceCalls.push("reload-functions"); + }), reloadEdgeRuntime: (config) => - Effect.sync(() => { - edgeRuntimeReloads.push(config); - serviceCalls.push("reload-edge-runtime"); - }), + options.notRunningPhase !== undefined + ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase })) + : Effect.sync(() => { + edgeRuntimeReloads.push(config); + serviceCalls.push("reload-edge-runtime"); + }), getState: (name: string) => { const match = MOCK_STATES.find((s) => s.name === name); return match ? Effect.succeed(match) : Effect.fail(new ServiceNotFoundError({ name })); @@ -570,6 +581,39 @@ describe("RemoteStack integration", () => { } }); + test("preserves StackNotRunningError across mutating daemon operations", async () => { + const failingMock = mockStack({ notRunningPhase: "stopped" }); + const failingServer = ManagedRuntime.make(buildServerLayer(failingMock)); + let failingClient: ManagedRuntime.ManagedRuntime | undefined; + try { + const daemon = await failingServer.runPromise(DaemonServer); + const addr = daemon.address; + if (addr._tag !== "TcpAddress") throw new Error("Expected TcpAddress"); + const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; + failingClient = ManagedRuntime.make(buildClientLayer(`http://${host}:${addr.port}`)); + + const operations = [ + (stack: Stack["Service"]) => stack.startService("auth"), + (stack: Stack["Service"]) => stack.stopService("auth"), + (stack: Stack["Service"]) => stack.restartService("auth"), + (stack: Stack["Service"]) => stack.reloadFunctions(), + (stack: Stack["Service"]) => + stack.reloadEdgeRuntime({ edgeRuntime: { policy: "oneshot" } }), + ]; + for (const operation of operations) { + const error = await failingClient.runPromise( + Effect.flatMap(Stack, operation).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(StackNotRunningError); + expect(error._tag).toBe("StackNotRunningError"); + if (error._tag === "StackNotRunningError") expect(error.phase).toBe("stopped"); + } + } finally { + await failingClient?.dispose(); + await failingServer.dispose(); + } + }); + test("preserves ServiceReadyError from remote startService", async () => { const failingMock = mockStack({ startServiceReadyError: "start failed readiness" }); const failingServer = ManagedRuntime.make(buildServerLayer(failingMock)); diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts index e133f53823..bb827cf52e 100644 --- a/packages/stack/src/RemoteStack.ts +++ b/packages/stack/src/RemoteStack.ts @@ -3,7 +3,7 @@ import { Effect, Layer, Schema, Stream } from "effect"; import * as Sse from "effect/unstable/encoding/Sse"; import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { DaemonErrorResponseSchema } from "./DaemonProtocol.ts"; -import { StackBuildError, StackReadinessError } from "./errors.ts"; +import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts"; import { Stack, StackInfoSchema } from "./Stack.ts"; import { inheritReadyOptions } from "./StackConfig.ts"; import { StackServiceState, StackServiceStatusSchema } from "./StackServiceState.ts"; @@ -138,7 +138,11 @@ const failDaemonResponse = ( fallbackName: string, ): Effect.Effect< never, - ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + | ServiceNotFoundError + | ServiceReadyError + | StackBuildError + | StackNotRunningError + | StackReadinessError > => Effect.gen(function* () { const body = yield* dieOnBodyDecodeError( @@ -166,6 +170,8 @@ const failDaemonResponse = ( timeoutMs: body.timeoutMs ?? 0, detail: body.error, }); + case "STACK_NOT_RUNNING": + return yield* new StackNotRunningError({ phase: body.phase ?? "unknown" }); } }); @@ -177,6 +183,25 @@ const expectDaemonOk = ( ): Effect.Effect< void, ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError +> => + response.status >= 200 && response.status < 300 + ? Effect.void + : failDaemonResponse(endpoint, path, response, fallbackName).pipe( + Effect.catchTag("StackNotRunningError", (error) => Effect.die(error)), + ); + +const expectMutatingDaemonOk = ( + endpoint: ControlEndpoint, + path: string, + response: HttpClientResponse.HttpClientResponse, + fallbackName: string, +): Effect.Effect< + void, + | ServiceNotFoundError + | ServiceReadyError + | StackBuildError + | StackNotRunningError + | StackReadinessError > => response.status >= 200 && response.status < 300 ? Effect.void @@ -386,7 +411,7 @@ export const RemoteStack = { const response = yield* httpResponse(endpoint, path, { method: "POST", }); - yield* expectDaemonOk(endpoint, path, response, name); + yield* expectMutatingDaemonOk(endpoint, path, response, name); }), ), @@ -398,7 +423,7 @@ export const RemoteStack = { const response = yield* httpResponse(endpoint, path, { method: "POST", }); - yield* expectDaemonOk(endpoint, path, response, name).pipe( + yield* expectMutatingDaemonOk(endpoint, path, response, name).pipe( Effect.catchTag("ServiceReadyError", (error) => Effect.die(error)), Effect.catchTag("StackReadinessError", (error) => Effect.die(error)), ); @@ -413,7 +438,7 @@ export const RemoteStack = { const response = yield* httpResponse(endpoint, path, { method: "POST", }); - yield* expectDaemonOk(endpoint, path, response, name); + yield* expectMutatingDaemonOk(endpoint, path, response, name); }), ), @@ -426,7 +451,7 @@ export const RemoteStack = { headers: { "content-type": "application/json" }, body: JSON.stringify(opts ?? {}), }); - yield* expectDaemonOk(endpoint, path, response, "edge-runtime"); + yield* expectMutatingDaemonOk(endpoint, path, response, "edge-runtime"); }), ), @@ -439,7 +464,7 @@ export const RemoteStack = { headers: { "content-type": "application/json" }, body: JSON.stringify(opts), }); - yield* expectDaemonOk(endpoint, path, response, "edge-runtime"); + yield* expectMutatingDaemonOk(endpoint, path, response, "edge-runtime"); }), ), diff --git a/packages/stack/src/ServiceActivation.ts b/packages/stack/src/ServiceActivation.ts index f61281abf4..78b45c0a2f 100644 --- a/packages/stack/src/ServiceActivation.ts +++ b/packages/stack/src/ServiceActivation.ts @@ -6,9 +6,12 @@ import { stackServiceStartupBudgetSeconds } from "./services/health-budgets.ts"; import { SERVICE_NAMES, serviceMetadata } from "./ServiceCatalog.ts"; import type { ServiceName } from "./ServiceName.ts"; import type { ReadinessPolicy } from "./StackConfig.ts"; +import type { ServicePolicyManifest } from "./StackConfig.ts"; -export const eagerServices = (enabled: ReadonlyArray): ReadonlyArray => - enabled.filter((service) => serviceMetadata(service).activation.startup === "eager"); +export const eagerServices = ( + enabled: ReadonlyArray, + policies: ServicePolicyManifest, +): ReadonlyArray => enabled.filter((service) => policies[service] === "eager"); export const activationTargetsForService = ( enabledServices: ReadonlyArray, diff --git a/packages/stack/src/ServiceActivation.unit.test.ts b/packages/stack/src/ServiceActivation.unit.test.ts index 0f28d4d6f2..531e155d26 100644 --- a/packages/stack/src/ServiceActivation.unit.test.ts +++ b/packages/stack/src/ServiceActivation.unit.test.ts @@ -7,6 +7,7 @@ import { lifecycleTargetsForService, } from "./ServiceActivation.ts"; import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; +import { DEFAULT_SERVICE_POLICIES } from "./ServiceCatalog.ts"; describe("service activation", () => { it("defines an access policy for every stack service", () => { @@ -14,7 +15,7 @@ describe("service activation", () => { }); it("starts direct endpoints eagerly", () => { - expect(eagerServices(SERVICE_NAMES)).toEqual([ + expect(eagerServices(SERVICE_NAMES, DEFAULT_SERVICE_POLICIES)).toEqual([ "postgres", "realtime", "mailpit", diff --git a/packages/stack/src/ServiceCatalog.ts b/packages/stack/src/ServiceCatalog.ts index ef6f7844bd..cf387c4ff0 100644 --- a/packages/stack/src/ServiceCatalog.ts +++ b/packages/stack/src/ServiceCatalog.ts @@ -1,25 +1,24 @@ import { Record } from "effect"; -import { - authAssetName, - edgeRuntimeAssetName, - postgresAssetName, - postgrestAssetName, - type PlatformInfo, -} from "./Platform.ts"; +import { nativeTargetForPlatform, type NativeTarget, type PlatformInfo } from "./Platform.ts"; import type { PortField } from "./PortCatalog.ts"; import type { ServiceName } from "./ServiceName.ts"; -type ArtifactOwnership = "supabase" | "upstream"; type ServiceRuntimeSupport = "native-preferred" | "docker-only"; -export type ArchiveFormat = "tar.gz" | "tar.xz" | "zip"; +type ArchiveFormat = "tar.zst"; +export type ServicePreparationPolicy = "off" | "lazy" | "eager"; export interface NativeReleaseArtifact { + readonly service: ServiceName; + readonly version: string; readonly provider: string; readonly assetName: string; + readonly releaseTag: string; + readonly target: NativeTarget; readonly archive: ArchiveFormat; readonly downloadUrl: string; - readonly checksumUrl: string | null; - readonly stripComponents: boolean; + readonly manifestUrl: string; + readonly checksumUrl: string; + readonly requiredRuntimePaths: ReadonlyArray; } interface NativeReleaseSource { @@ -28,7 +27,6 @@ interface NativeReleaseSource { } interface DockerImageSource { - readonly ownership: ArtifactOwnership; readonly repository: string; readonly tagPrefix?: string; } @@ -39,14 +37,20 @@ interface ServiceArtifactDefinition { } interface ServiceActivationPolicy { - /** Whether the public service must already be running when lazy startup completes. */ - readonly startup: "eager" | "lazy"; /** Other public services required when this service is activated. */ readonly activates: ReadonlyArray; /** Private companions whose lifecycle is exclusively owned by this service. */ readonly owns: ReadonlyArray; } +export interface ServicePreparationMetadata { + /** Policies supported by the service's runtime/resource implementation. */ + readonly supported: ReadonlyArray>; + readonly default: Exclude; + /** Services whose resources must be materialized before this service can start. */ + readonly dependencies: ReadonlyArray; +} + type ServiceConfigKey = | "postgres" | "postgrest" @@ -69,36 +73,50 @@ export interface ServiceCatalogEntry { readonly runtimeSupport: ServiceRuntimeSupport; readonly artifact: ServiceArtifactDefinition; readonly activation: ServiceActivationPolicy; + readonly preparation: ServicePreparationMetadata; readonly portFields: ReadonlyArray; } -const SUPABASE_ECR_REGISTRY = "public.ecr.aws/supabase"; -const SUPABASE_DOCKER_HUB_REGISTRY = "supabase"; -const SUPABASE_GHCR_REGISTRY = "ghcr.io/supabase"; +const SUPABASE_GHCR_REGISTRY = "ghcr.io/supabase/cli"; +const SLIM_RELEASE_BASE = "https://github.com/supabase/slim-services/releases/download"; const nativeRelease = ( - provider: string, - assetName: string | null, - archive: ArchiveFormat, - downloadUrl: string, - options?: { - readonly checksumUrl?: string; - readonly stripComponents?: boolean; + service: ServiceName, + version: string, + platform: PlatformInfo, + options: { + readonly requiredRuntimePaths: ReadonlyArray; }, -): NativeReleaseArtifact | undefined => - assetName === null - ? undefined - : { - provider, - assetName, - archive, - downloadUrl, - checksumUrl: options?.checksumUrl ?? null, - stripComponents: options?.stripComponents ?? false, - }; +): NativeReleaseArtifact | undefined => { + const target = nativeTargetForPlatform(platform); + if (target === undefined) return undefined; + const releaseTag = `${service}-${version}`; + const base = `${SLIM_RELEASE_BASE}/${releaseTag}`; + const assetName = `${releaseTag}-${target}`; + return { + service, + version, + provider: "github.com/supabase/slim-services", + assetName, + releaseTag, + target, + archive: "tar.zst", + downloadUrl: `${base}/${assetName}.tar.zst`, + manifestUrl: `${base}/${assetName}.manifest.json`, + checksumUrl: `${base}/SHA256SUMS`, + requiredRuntimePaths: options.requiredRuntimePaths, + }; +}; -const authReleaseTag = (version: string): string => - version.includes("-rc.") ? `rc${version}` : `v${version}`; +const preparation = ( + supported: ReadonlyArray>, + defaultPolicy: Exclude, + dependencies: ReadonlyArray = [], +): ServicePreparationMetadata => ({ + supported, + default: defaultPolicy, + dependencies, +}); /** * Exhaustive static identity and capability metadata for public stack services. @@ -108,112 +126,102 @@ export const SERVICE_CATALOG = { postgres: { name: "postgres", configKey: "postgres", - defaultVersion: "17.6.1.159", + defaultVersion: "17.6.1.163", runtimeSupport: "native-preferred", artifact: { - docker: { ownership: "supabase", repository: "postgres" }, + docker: { repository: "postgres" }, native: { - provider: "github.com/supabase/postgres", - resolve: (version, platform) => { - const assetName = postgresAssetName(platform); - const cliVersion = `${version}-cli`; - const url = `https://github.com/supabase/postgres/releases/download/v${cliVersion}/supabase-postgres-v${cliVersion}-${assetName}.tar.gz`; - return nativeRelease("github.com/supabase/postgres", assetName, "tar.gz", url, { - checksumUrl: `${url}.sha256`, - stripComponents: true, - }); - }, + provider: "github.com/supabase/slim-services", + resolve: (version, platform) => + nativeRelease("postgres", version, platform, { + requiredRuntimePaths: [ + "bin/postgres", + "bin/pg_isready", + "bin/psql", + "share/supabase-cli/bin/supabase-postgres-init.sh", + "share/supabase-cli/config/pgsodium_getkey.sh", + "share/supabase-cli/migrations", + "lib", + ], + }), }, }, - activation: { startup: "eager", activates: [], owns: [] }, + activation: { activates: [], owns: [] }, + preparation: preparation(["eager"], "eager"), portFields: ["dbPort"], }, postgrest: { name: "postgrest", configKey: "postgrest", - defaultVersion: "16.1", + defaultVersion: "v16.1", runtimeSupport: "native-preferred", artifact: { - docker: { ownership: "supabase", repository: "postgrest", tagPrefix: "v" }, + docker: { repository: "postgrest" }, native: { - provider: "github.com/PostgREST/postgrest", - resolve: (version, platform) => { - const assetName = postgrestAssetName(platform); - const archive = assetName?.startsWith("windows") === true ? "zip" : "tar.xz"; - return nativeRelease( - "github.com/PostgREST/postgrest", - assetName, - archive, - `https://github.com/PostgREST/postgrest/releases/download/v${version}/postgrest-v${version}-${assetName}.${archive}`, - ); - }, + provider: "github.com/supabase/slim-services", + resolve: (version, platform) => + nativeRelease("postgrest", version, platform, { + requiredRuntimePaths: ["bin/postgrest"], + }), }, }, - activation: { startup: "lazy", activates: [], owns: [] }, + activation: { activates: [], owns: [] }, + preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), portFields: ["postgrestPort", "postgrestAdminPort"], }, auth: { name: "auth", configKey: "auth", - defaultVersion: "2.195.0", + defaultVersion: "v2.195.0", runtimeSupport: "native-preferred", artifact: { - docker: { ownership: "supabase", repository: "gotrue", tagPrefix: "v" }, + docker: { repository: "auth" }, native: { - provider: "github.com/supabase/auth", - resolve: (version, platform) => { - const assetName = authAssetName(platform); - return nativeRelease( - "github.com/supabase/auth", - assetName, - "tar.gz", - `https://github.com/supabase/auth/releases/download/${authReleaseTag(version)}/auth-v${version}-${assetName}.tar.gz`, - ); - }, + provider: "github.com/supabase/slim-services", + resolve: (version, platform) => + nativeRelease("auth", version, platform, { + requiredRuntimePaths: ["bin/auth"], + }), }, }, - activation: { startup: "lazy", activates: [], owns: [] }, + activation: { activates: [], owns: [] }, + preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), portFields: ["authPort"], }, "edge-runtime": { name: "edge-runtime", configKey: "edgeRuntime", - defaultVersion: "1.74.3", + defaultVersion: "v1.74.3", runtimeSupport: "docker-only", artifact: { - docker: { ownership: "supabase", repository: "edge-runtime", tagPrefix: "v" }, - native: { - provider: "github.com/supabase/edge-runtime", - resolve: (version, platform) => { - const assetName = edgeRuntimeAssetName(platform); - return nativeRelease( - "github.com/supabase/edge-runtime", - assetName, - "tar.gz", - `https://github.com/supabase/edge-runtime/releases/download/v${version}/edge-runtime-v${version}-${assetName}.tar.gz`, - ); - }, - }, + docker: { repository: "edge-runtime" }, }, - activation: { startup: "lazy", activates: [], owns: [] }, + activation: { activates: [], owns: [] }, + preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), portFields: ["edgeRuntimePort", "edgeRuntimeInspectorPort"], }, realtime: { name: "realtime", configKey: "realtime", - defaultVersion: "2.129.0", + defaultVersion: "v2.129.1", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "supabase", repository: "realtime", tagPrefix: "v" } }, - activation: { startup: "eager", activates: [], owns: [] }, + artifact: { + docker: { repository: "realtime" }, + }, + activation: { activates: [], owns: [] }, + preparation: preparation(["eager"], "eager", ["postgres"]), portFields: ["realtimePort"], }, storage: { name: "storage", configKey: "storage", - defaultVersion: "1.69.11", + defaultVersion: "v1.70.1", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "supabase", repository: "storage-api", tagPrefix: "v" } }, - activation: { startup: "lazy", activates: ["imgproxy"], owns: ["imgproxy"] }, + artifact: { + docker: { repository: "storage" }, + }, + activation: { activates: ["imgproxy"], owns: ["imgproxy"] }, + preparation: preparation(["lazy", "eager"], "lazy", ["postgres", "imgproxy"]), portFields: ["storagePort"], }, imgproxy: { @@ -221,8 +229,11 @@ export const SERVICE_CATALOG = { configKey: "imgproxy", defaultVersion: "v3.8.0", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "upstream", repository: "darthsim/imgproxy" } }, - activation: { startup: "lazy", activates: [], owns: [] }, + artifact: { + docker: { repository: "imgproxy" }, + }, + activation: { activates: [], owns: [] }, + preparation: preparation(["lazy", "eager"], "lazy"), portFields: ["imgproxyPort"], }, mailpit: { @@ -230,8 +241,11 @@ export const SERVICE_CATALOG = { configKey: "mailpit", defaultVersion: "v1.30.2", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "upstream", repository: "axllent/mailpit" } }, - activation: { startup: "eager", activates: [], owns: [] }, + artifact: { + docker: { repository: "mailpit" }, + }, + activation: { activates: [], owns: [] }, + preparation: preparation(["eager"], "eager"), portFields: ["mailpitPort", "mailpitSmtpPort", "mailpitPop3Port"], }, pgmeta: { @@ -240,9 +254,10 @@ export const SERVICE_CATALOG = { defaultVersion: "0.98.0", runtimeSupport: "docker-only", artifact: { - docker: { ownership: "supabase", repository: "postgres-meta", tagPrefix: "v" }, + docker: { repository: "pgmeta", tagPrefix: "v" }, }, - activation: { startup: "lazy", activates: [], owns: [] }, + activation: { activates: [], owns: [] }, + preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), portFields: ["pgmetaPort"], }, studio: { @@ -250,35 +265,47 @@ export const SERVICE_CATALOG = { configKey: "studio", defaultVersion: "2026.08.17-sha-0c1da8f", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "supabase", repository: "studio" } }, - activation: { startup: "eager", activates: ["analytics"], owns: [] }, + artifact: { + docker: { repository: "studio" }, + }, + activation: { activates: ["analytics"], owns: [] }, + preparation: preparation(["eager"], "eager", ["pgmeta", "analytics"]), portFields: ["studioPort"], }, analytics: { name: "analytics", configKey: "analytics", - defaultVersion: "1.50.2", + defaultVersion: "v1.50.3", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "supabase", repository: "logflare" } }, - activation: { startup: "lazy", activates: ["vector"], owns: ["vector"] }, + artifact: { + docker: { repository: "analytics" }, + }, + activation: { activates: ["vector"], owns: ["vector"] }, + preparation: preparation(["lazy", "eager"], "lazy", ["postgres", "vector"]), portFields: ["analyticsPort"], }, vector: { name: "vector", configKey: "vector", - defaultVersion: "0.53.0-alpine", + defaultVersion: "0.53.0", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "upstream", repository: "timberio/vector" } }, - activation: { startup: "lazy", activates: [], owns: [] }, + artifact: { + docker: { repository: "vector" }, + }, + activation: { activates: [], owns: [] }, + preparation: preparation(["lazy", "eager"], "lazy"), portFields: [], }, pooler: { name: "pooler", configKey: "pooler", - defaultVersion: "2.9.7", + defaultVersion: "v2.9.10", runtimeSupport: "docker-only", - artifact: { docker: { ownership: "supabase", repository: "supavisor" } }, - activation: { startup: "eager", activates: [], owns: [] }, + artifact: { + docker: { repository: "pooler" }, + }, + activation: { activates: [], owns: [] }, + preparation: preparation(["eager"], "eager", ["postgres"]), portFields: ["poolerPort", "poolerApiPort"], }, } satisfies { readonly [Name in ServiceName]: ServiceCatalogEntry }; @@ -303,35 +330,14 @@ export const nativeReleaseForService = ( export const isDockerOnlyService = (service: ServiceName): boolean => SERVICE_CATALOG[service].runtimeSupport === "docker-only"; -const dockerTag = (service: ServiceName, version: string): string => { - const source = serviceMetadata(service).artifact.docker; - return `${source.tagPrefix ?? ""}${version}`; -}; +export const DEFAULT_SERVICE_POLICIES: Readonly< + Record> +> = Record.map(SERVICE_CATALOG, (metadata) => metadata.preparation.default); -export const dockerImageForArtifact = (service: ServiceName, version: string): string => { - const source = SERVICE_CATALOG[service].artifact.docker; - const repository = - source.ownership === "supabase" - ? `${SUPABASE_ECR_REGISTRY}/${source.repository}` - : source.repository; - return `${repository}:${dockerTag(service, version)}`; -}; +export const requiredPreparationDependencies = (service: ServiceName): ReadonlyArray => + serviceMetadata(service).preparation.dependencies; -export const dockerImageCandidatesForArtifact = ( - service: ServiceName, - version: string, -): ReadonlyArray => { - const source = SERVICE_CATALOG[service].artifact.docker; - const tag = dockerTag(service, version); - if (source.ownership === "upstream") { - return [`${source.repository}:${tag}`]; - } - return [ - `${SUPABASE_ECR_REGISTRY}/${source.repository}:${tag}`, - `${SUPABASE_DOCKER_HUB_REGISTRY}/${source.repository}:${tag}`, - `${SUPABASE_GHCR_REGISTRY}/${source.repository}:${tag}`, - ]; +export const dockerImageForArtifact = (service: ServiceName, version: string): string => { + const source = serviceMetadata(service).artifact.docker; + return `${SUPABASE_GHCR_REGISTRY}/${source.repository}:${source.tagPrefix ?? ""}${version}`; }; - -export const imageTagPrefixForService = (service: ServiceName): string | undefined => - serviceMetadata(service).artifact.docker.tagPrefix; diff --git a/packages/stack/src/ServicePorts.ts b/packages/stack/src/ServicePorts.ts index 6c1a7944e1..5674f87ce9 100644 --- a/packages/stack/src/ServicePorts.ts +++ b/packages/stack/src/ServicePorts.ts @@ -2,15 +2,13 @@ import { PORT_CATALOG, PORT_FIELDS, type PortField } from "./PortCatalog.ts"; import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; import type { StackConfig } from "./StackConfig.ts"; -export const serviceEnabledForConfig = ( - config: StackConfig, - service: keyof typeof SERVICE_CATALOG, -) => { +const serviceEnabledForConfig = (config: StackConfig, service: keyof typeof SERVICE_CATALOG) => { + if (config.servicePolicies?.[service] === "off") return false; if (service === "postgres" || service === "postgrest" || service === "auth") { return config[service === "postgres" ? "postgres" : service] !== false; } if (service === "edge-runtime") { - const mode = config.mode ?? "auto"; + const mode = config.mode ?? "native"; return ( !(mode === "native" && config.edgeRuntime === undefined) && config.edgeRuntime !== false && diff --git a/packages/stack/src/Stack.ts b/packages/stack/src/Stack.ts index 9693b08d02..1a53cdb069 100644 --- a/packages/stack/src/Stack.ts +++ b/packages/stack/src/Stack.ts @@ -1,7 +1,7 @@ import { ServiceNotFoundError } from "@supabase/process-compose"; import type { LogEntry, ServiceReadyError } from "@supabase/process-compose"; import { Context, Effect, Schema, Stream } from "effect"; -import { StackBuildError, StackReadinessError } from "./errors.ts"; +import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts"; import { ResolvedFunctionsBundleSchema, type FunctionsReloadConfig, @@ -61,28 +61,44 @@ export class Stack extends Context.Service< name: string, ) => Effect.Effect< void, - ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + | ServiceNotFoundError + | ServiceReadyError + | StackBuildError + | StackNotRunningError + | StackReadinessError >; readonly stopService: ( name: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly restartService: ( name: string, ) => Effect.Effect< void, - ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + | ServiceNotFoundError + | ServiceReadyError + | StackBuildError + | StackNotRunningError + | StackReadinessError >; readonly reloadFunctions: ( opts?: FunctionsReloadConfig, ) => Effect.Effect< void, - ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + | ServiceNotFoundError + | ServiceReadyError + | StackBuildError + | StackNotRunningError + | StackReadinessError >; readonly reloadEdgeRuntime: ( opts: EdgeRuntimeReloadConfig, ) => Effect.Effect< void, - ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError + | ServiceNotFoundError + | ServiceReadyError + | StackBuildError + | StackNotRunningError + | StackReadinessError >; readonly getState: (name: string) => Effect.Effect; readonly getAllStates: () => Effect.Effect>; diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index 1eb8a84283..32f5fffe92 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; +import { NodeServices } from "@effect/platform-node"; import { buildGraph } from "@supabase/process-compose"; import { createHmac } from "node:crypto"; import { mkdtempSync } from "node:fs"; import { readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"; +import { createServer, type Server } from "node:http"; +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"; import { mockChildProcessSpawner } from "../../process-compose/tests/helpers/mocks.ts"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; import { StackBuildError } from "./errors.ts"; @@ -51,7 +52,22 @@ const defaultConfig: ResolvedStackConfig = { runtimeRoot: "/tmp/supabase-runtime", projectDir: "/tmp/supabase-project", mode: "native", - startupMode: "eager", + containerRuntime: null, + servicePolicies: { + postgres: "eager", + postgrest: "eager", + auth: "eager", + "edge-runtime": "off", + realtime: "off", + storage: "off", + imgproxy: "off", + mailpit: "off", + pgmeta: "off", + studio: "off", + analytics: "off", + vector: "off", + pooler: "off", + }, readiness: DEFAULT_STACK_READINESS_POLICY, readinessSource: "default", jwtSecret: testJwtSecret, @@ -99,7 +115,9 @@ const defaultConfig: ResolvedStackConfig = { const edgeRuntimeConfig: ResolvedStackConfig = { ...defaultConfig, - mode: "auto", + mode: "docker", + containerRuntime: "docker", + servicePolicies: { ...defaultConfig.servicePolicies, "edge-runtime": "eager" }, edgeRuntime: { enabled: true, port: defaultPorts.edgeRuntimePort, @@ -135,14 +153,14 @@ function setupLayer( config: ResolvedStackConfig = defaultConfig, portLease: PortLease = noopPortLease(config.ports), spawner = mockChildProcessSpawner(), + resolver = mockBinaryResolver(), ) { - const resolver = mockBinaryResolver(); const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); const layer = localStackLayer(config, portLease).pipe( Layer.provide(StackBuilder.layer), Layer.provide(stackPreparationLayer), Layer.provide(spawner.layer), - Layer.provide(BunServices.layer), + Layer.provide(NodeServices.layer), ); return { layer, resolver, spawner }; @@ -184,9 +202,22 @@ describe("Stack", () => { projectDir: runtimeRoot, runtimeRoot, functions: initialBundle, + postgrest: false, + auth: false, + servicePolicies: { + ...edgeRuntimeConfig.servicePolicies, + postgrest: "off", + auth: "off", + "edge-runtime": "lazy", + }, } satisfies ResolvedStackConfig; const graph = Effect.runSync( buildGraph([ + { + name: "postgres", + command: process.execPath, + restart: "unless-stopped", + }, { name: "edge-runtime", command: process.execPath, @@ -202,7 +233,10 @@ describe("Stack", () => { return { graph, cleanupTargets: { dockerContainerNames: [] }, - serviceProjection: new Map([["edge-runtime", { visibility: "public" as const }]]), + serviceProjection: new Map([ + ["postgres", { visibility: "public" as const }], + ["edge-runtime", { visibility: "public" as const }], + ]), }; }), }); @@ -211,7 +245,7 @@ describe("Stack", () => { Layer.provide(builderLayer), Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), Layer.provide(mockChildProcessSpawner().layer), - Layer.provide(BunServices.layer), + Layer.provide(NodeServices.layer), ); const readRuntimeConfig = Effect.promise(() => readFile(functionsRuntimeConfigPath(runtimeRoot), "utf8").then((contents) => @@ -222,8 +256,10 @@ describe("Stack", () => { return Effect.gen(function* () { const stack = yield* Stack; yield* stack.start(); + expect((yield* stack.getState("edge-runtime")).status).toBe("Dormant"); yield* stack.reloadFunctions({ functions: replacementBundle }); + expect((yield* stack.getState("edge-runtime")).status).toBe("Healthy"); expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); yield* stack.reloadEdgeRuntime({ edgeRuntime: { policy: "oneshot" } }); @@ -278,6 +314,97 @@ describe("Stack", () => { ); }); + it.live("merges overlapping function and Edge Runtime reloads", () => { + const runtimeRoot = mkdtempSync(join(tmpdir(), "supabase-functions-reload-race-")); + const initialBundle = functionsBundle(runtimeRoot, "initial-secret"); + const replacementBundle = functionsBundle(runtimeRoot, "replacement-secret"); + const config = { + ...edgeRuntimeConfig, + projectDir: runtimeRoot, + runtimeRoot, + functions: initialBundle, + postgrest: false, + auth: false, + servicePolicies: { + ...edgeRuntimeConfig.servicePolicies, + postgrest: "off", + auth: "off", + "edge-runtime": "lazy", + }, + } satisfies ResolvedStackConfig; + const graph = Effect.runSync( + buildGraph([ + { name: "postgres", command: process.execPath, restart: "unless-stopped" }, + { name: "edge-runtime", command: process.execPath, restart: "unless-stopped" }, + ]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.sync(() => { + return { + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([ + ["postgres", { visibility: "public" as const }], + ["edge-runtime", { visibility: "public" as const }], + ]), + }; + }), + }); + const preparationStarted = Deferred.makeUnsafe(); + const allowPreparation = Deferred.makeUnsafe(); + let blockNextSpawn = false; + const resolver = mockBinaryResolver(); + const spawner = mockChildProcessSpawner({ + beforeSpawn: () => { + if (!blockNextSpawn) return Effect.void; + blockNextSpawn = false; + return Deferred.succeed(preparationStarted, undefined).pipe( + Effect.andThen(Deferred.await(allowPreparation)), + ); + }, + }); + const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( + Layer.provide(builderLayer), + Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), + Layer.provide(spawner.layer), + Layer.provide(NodeServices.layer), + ); + const readRuntimeConfig = Effect.promise(() => + readFile(functionsRuntimeConfigPath(runtimeRoot), "utf8").then((contents) => + JSON.parse(contents), + ), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + blockNextSpawn = true; + + const functionsReload = yield* stack + .reloadFunctions({ functions: replacementBundle }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(preparationStarted); + + // Both requests join the same gated preparation before either can commit. + // The later Edge Runtime commit must preserve the Functions update. + const edgeReload = yield* stack + .reloadEdgeRuntime({ edgeRuntime: { env: { CONCURRENT: "edge-value" } } }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(allowPreparation, undefined); + yield* Fiber.join(functionsReload); + yield* Fiber.join(edgeReload); + + expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + expect((yield* stack.getState("edge-runtime")).status).toBe("Healthy"); + yield* stack.dispose(); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.promise(() => rm(runtimeRoot, { recursive: true, force: true }))), + Effect.timeout("5 seconds"), + ); + }); + it.effect("getInfo returns valid JWT tokens", () => { const { layer } = setupLayer(); @@ -422,39 +549,6 @@ describe("Stack", () => { }).pipe(Effect.provide(layer)); }); - it.effect("emits Downloading when a service fetches assets before startup", () => { - const resolver = mockBinaryResolver({ - downloadedServices: ["postgres"], - downloadDelayMs: 20, - }); - const spawner = mockChildProcessSpawner(); - const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); - const layer = localStackLayer(defaultConfig, noopPortLease(defaultConfig.ports)).pipe( - Layer.provide(StackBuilder.layer), - Layer.provide(stackPreparationLayer), - ); - const providedLayer = layer.pipe( - Layer.provide(spawner.layer), - Layer.provide(BunServices.layer), - ); - - return Effect.gen(function* () { - const stack = yield* Stack; - const statesFiber = yield* stack.allStateChanges().pipe( - Stream.filter((state) => state.name === "postgres"), - Stream.take(2), - Stream.runCollect, - Effect.forkChild({ startImmediately: true }), - ); - - const startFiber = yield* stack.start().pipe(Effect.forkChild({ startImmediately: true })); - const states = yield* Fiber.join(statesFiber); - yield* Fiber.interrupt(startFiber); - - expect(states.map((state) => state.status)).toContain("Downloading"); - }).pipe(Effect.provide(providedLayer)); - }); - it.live("starts the readiness deadline after artifact preparation", () => { const resolver = mockBinaryResolver({ downloadedServices: ["postgres"], @@ -473,7 +567,7 @@ describe("Stack", () => { Layer.provide(StackBuilder.layer), Layer.provide(stackPreparationLayer), Layer.provide(spawner.layer), - Layer.provide(BunServices.layer), + Layer.provide(NodeServices.layer), ); return Effect.gen(function* () { @@ -486,6 +580,26 @@ describe("Stack", () => { }).pipe(Effect.provide(layer), Effect.scoped, Effect.timeout("5 seconds")); }); + it.effect("rejects unsupported native services before resource planning", () => { + const config = { + ...edgeRuntimeConfig, + mode: "native", + containerRuntime: null, + } satisfies ResolvedStackConfig; + const { layer } = setupLayer(config, noopPortLease(config.ports)); + + return Effect.gen(function* () { + const stack = yield* Stack; + const error = yield* stack.start().pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "StackBuildError", + reason: "invalid_config", + }); + if (error._tag === "StackBuildError") expect(error.detail).toContain("edge-runtime"); + }).pipe(Effect.provide(layer)); + }); + it.effect("getState fails for internal helper services", () => { const { layer } = setupLayer(); @@ -518,14 +632,26 @@ describe("Stack", () => { }).pipe(Effect.provide(layer)); }); - it.effect("startService fails with ServiceNotFoundError for unknown service", () => { - const { layer } = setupLayer(); + it.live("startService fails with ServiceNotFoundError for unknown service", () => { + const config = { + ...defaultConfig, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "lazy", + auth: "lazy", + }, + } satisfies ResolvedStackConfig; + const { layer } = setupLayer(config); return Effect.gen(function* () { const stack = yield* Stack; + yield* stack.start(); const exit = yield* stack.startService("nonexistent").pipe(Effect.exit); expect(exit._tag).toBe("Failure"); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "ServiceNotFoundError" }); + } }).pipe(Effect.provide(layer)); }); @@ -546,7 +672,7 @@ describe("Stack", () => { ); const providedLayer = layer.pipe( Layer.provide(spawner.layer), - Layer.provide(BunServices.layer), + Layer.provide(NodeServices.layer), ); return Effect.gen(function* () { @@ -560,6 +686,40 @@ describe("Stack", () => { }).pipe(Effect.provide(providedLayer)); }); + it.live("disposal fails a cold eager start with a typed build error", () => { + return Effect.gen(function* () { + const preparationStarted = yield* Deferred.make(); + const resolver = mockBinaryResolver({ + downloadedServices: ["auth"], + beforeResolve: ({ service }) => + service === "auth" + ? Deferred.succeed(preparationStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + }); + const { layer } = setupLayer( + defaultConfig, + noopPortLease(defaultConfig.ports), + undefined, + resolver, + ); + + yield* Effect.gen(function* () { + const stack = yield* Stack; + const starting = yield* stack.start().pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(preparationStarted); + + const disposing = yield* stack.dispose().pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.join(disposing); + + const startExit = yield* Fiber.await(starting); + expect(Exit.isFailure(startExit)).toBe(true); + if (Exit.isFailure(startExit)) { + expect(Cause.squash(startExit.cause)).toMatchObject({ _tag: "StackBuildError" }); + } + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.timeout("5 seconds")); + }); + it.live("can retry start after a build failure before services start", () => { let buildAttempts = 0; const graph = Effect.runSync( @@ -587,19 +747,215 @@ describe("Stack", () => { Layer.provide(builderLayer), Layer.provide(stackPreparationLayer), Layer.provide(spawner.layer), - Layer.provide(BunServices.layer), + Layer.provide(NodeServices.layer), ); return Effect.gen(function* () { const stack = yield* Stack; - expect(Exit.isFailure(yield* stack.start().pipe(Effect.exit))).toBe(true); yield* stack.start(); - expect(buildAttempts).toBe(2); }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); + it.live("can retry start after asset preparation fails before services start", () => { + const resolver = mockBinaryResolver({ + failOnceServices: ["postgres"], + }); + const config = { + ...defaultConfig, + postgrest: false, + auth: false, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "off", + auth: "off", + }, + } satisfies ResolvedStackConfig; + const { layer } = setupLayer(config, noopPortLease(config.ports), undefined, resolver); + + return Effect.gen(function* () { + const stack = yield* Stack; + expect(Exit.isFailure(yield* stack.start().pipe(Effect.exit))).toBe(true); + yield* stack.start(); + expect((yield* stack.getState("postgres")).status).toBe("Healthy"); + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); + }); + + it.live("restarts activated companions after stopping the stack", () => { + const graph = Effect.runSync( + buildGraph([ + { + name: "postgres", + command: process.execPath, + restart: "no", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + { + name: "postgrest", + command: process.execPath, + restart: "no", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + { + name: "pgmeta", + command: process.execPath, + restart: "no", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + { + name: "studio", + command: process.execPath, + restart: "no", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + { + name: "analytics", + command: process.execPath, + restart: "no", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + { + name: "vector", + command: process.execPath, + restart: "no", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + ]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([ + ["postgres", { visibility: "public" as const }], + ["postgrest", { visibility: "public" as const }], + ["pgmeta", { visibility: "public" as const }], + ["studio", { visibility: "public" as const }], + ["analytics", { visibility: "public" as const }], + ["vector", { visibility: "public" as const }], + ]), + }), + }); + const config = { + ...defaultConfig, + mode: "docker", + containerRuntime: "docker", + pgmeta: { port: defaultPorts.pgmetaPort, version: DEFAULT_VERSIONS.pgmeta }, + studio: { + port: defaultPorts.studioPort, + apiUrl: "http://127.0.0.1:54321", + version: DEFAULT_VERSIONS.studio, + }, + analytics: { + port: defaultPorts.analyticsPort, + version: DEFAULT_VERSIONS.analytics, + backend: "postgres", + apiKey: "test-api-key", + }, + vector: { version: DEFAULT_VERSIONS.vector }, + servicePolicies: { + ...defaultConfig.servicePolicies, + auth: "lazy", + pgmeta: "eager", + studio: "eager", + analytics: "eager", + vector: "eager", + }, + } satisfies ResolvedStackConfig; + const { resolver, spawner } = setupLayer(config, noopPortLease(config.ports)); + const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); + const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( + Layer.provide(builderLayer), + Layer.provide(stackPreparationLayer), + Layer.provide(spawner.layer), + Layer.provide(NodeServices.layer), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + yield* stack.stop(); + yield* stack.start(); + + expect((yield* stack.getState("studio")).status).toBe("Healthy"); + expect((yield* stack.getState("analytics")).status).toBe("Healthy"); + expect((yield* stack.getState("vector")).status).toBe("Healthy"); + }).pipe(Effect.provide(layer), Effect.timeout("10 seconds")); + }); + + it.live("rejects a cached start when disposal begins during startup", () => + Effect.gen(function* () { + const startEntered = yield* Deferred.make(); + const releaseStart = yield* Deferred.make(); + const config = { + ...defaultConfig, + postgrest: false, + auth: false, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "off", + auth: "off", + }, + } satisfies ResolvedStackConfig; + const graph = Effect.runSync( + buildGraph([{ name: "postgres", command: "true", restart: "no" }]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([["postgres", { visibility: "public" as const }]]), + }), + }); + let gateNextStart = false; + const portLease: PortLease = { + ports: config.ports, + reserve: () => { + if (!gateNextStart) return Effect.void; + gateNextStart = false; + return Deferred.succeed(startEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseStart)), + ); + }, + release: () => Effect.void, + releaseAll: Effect.void, + }; + const resolver = mockBinaryResolver(); + const layer = localStackLayer(config, portLease).pipe( + Layer.provide(builderLayer), + Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), + Layer.provide(mockChildProcessSpawner().layer), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + yield* stack.stop(); + + gateNextStart = true; + const holder = yield* stack.start().pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(startEntered); + const disposing = yield* stack.dispose().pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.succeed(releaseStart, undefined); + const holderExit = yield* Fiber.await(holder); + expect(Exit.isFailure(holderExit)).toBe(true); + if (Exit.isFailure(holderExit)) { + expect(Cause.squash(holderExit.cause)).toMatchObject({ _tag: "StackBuildError" }); + } + yield* Fiber.join(disposing); + + expect((yield* stack.getState("postgres")).status).toBe("Stopped"); + const afterDisposal = yield* stack.start().pipe(Effect.flip); + expect(afterDisposal._tag).toBe("StackBuildError"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.timeout("5 seconds")), + ); + it.live("a partial startup failure disposes resources from services already started", () => { let cleaned = false; const spawner = mockChildProcessSpawner({ @@ -648,7 +1004,7 @@ describe("Stack", () => { Layer.provide(builderLayer), Layer.provide(stackPreparationLayer), Layer.provide(spawner.layer), - Layer.provide(BunServices.layer), + Layer.provide(NodeServices.layer), ); return Effect.gen(function* () { @@ -662,58 +1018,222 @@ describe("Stack", () => { }); it.live("lazy startup starts direct services without starting HTTP backends", () => { - const { layer, spawner } = setupLayer({ ...defaultConfig, startupMode: "lazy" }); + const { layer } = setupLayer({ + ...defaultConfig, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "lazy", + auth: "lazy", + storage: "lazy", + imgproxy: "lazy", + }, + }); return Effect.gen(function* () { const stack = yield* Stack; yield* stack.start(); yield* stack.waitAllReady(); - expect( - spawner.spawned.some((record) => - record.args.some((arg) => - Buffer.from(arg, "base64url").toString().includes('"command":"bash"'), - ), - ), - ).toBe(true); - expect(spawner.spawned.some((record) => record.command.endsWith("/auth"))).toBe(false); - expect(spawner.spawned.some((record) => record.command.endsWith("/postgrest"))).toBe(false); + expect((yield* stack.getState("postgres")).status).toBe("Healthy"); + expect((yield* stack.getState("auth")).status).toBe("Dormant"); + expect((yield* stack.getState("postgrest")).status).toBe("Dormant"); yield* stack.stop(); }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); - it.live("lazy activation honors explicitly stopped transitive dependencies", () => { - const config: ResolvedStackConfig = { + it.live("prepares a dormant service before restarting it", () => + Effect.gen(function* () { + const resolver = mockBinaryResolver({ downloadedServices: ["postgrest"] }); + const config = { + ...defaultConfig, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "lazy", + auth: "lazy", + }, + } satisfies ResolvedStackConfig; + const { layer } = setupLayer(config, noopPortLease(config.ports), undefined, resolver); + + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + const stateChanges = yield* stack.stateChanges("postgrest"); + const downloading = yield* stateChanges.pipe( + Stream.filter((state) => state.status === "Downloading"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const running = yield* stateChanges.pipe( + Stream.filter((state) => state.status === "Running"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const restarting = yield* stack + .restartService("postgrest") + .pipe(Effect.forkChild({ startImmediately: true })); + + expect((yield* Fiber.join(downloading))._tag).toBe("Some"); + expect((yield* Fiber.join(running))._tag).toBe("Some"); + + yield* Fiber.interrupt(restarting); + yield* stack.stop(); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.timeout("5 seconds")), + ); + + it.live("does not restart a service after the stack stops during preparation", () => + Effect.gen(function* () { + const allowPreparation = yield* Deferred.make(); + const resolver = mockBinaryResolver({ + downloadedServices: ["postgrest"], + beforeResolve: ({ service }) => + service === "postgrest" ? Deferred.await(allowPreparation) : Effect.void, + }); + const config = { + ...defaultConfig, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "lazy", + auth: "lazy", + }, + } satisfies ResolvedStackConfig; + const { layer } = setupLayer(config, noopPortLease(config.ports), undefined, resolver); + + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + const downloading = yield* (yield* stack.stateChanges("postgrest")).pipe( + Stream.filter((state) => state.status === "Downloading"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const restarting = yield* stack + .restartService("postgrest") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.join(downloading); + const running = yield* (yield* stack.stateChanges("postgrest")).pipe( + Stream.filter((state) => state.status === "Running"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + + yield* stack.stop(); + yield* Deferred.succeed(allowPreparation, undefined); + const outcome = yield* Effect.race( + Fiber.join(restarting).pipe( + Effect.exit, + Effect.map((exit) => ({ type: "restart" as const, exit })), + ), + Fiber.join(running).pipe(Effect.as({ type: "resurrected" as const })), + ); + + expect(outcome.type).toBe("restart"); + if (outcome.type === "restart") expect(Exit.isFailure(outcome.exit)).toBe(true); + expect((yield* stack.getState("postgrest")).status).not.toBe("Running"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.timeout("5 seconds")), + ); + + it.live("lazy activation restores dormant state after a stopped transitive dependency", () => { + const config = { ...defaultConfig, - mode: "auto", - startupMode: "lazy", - storage: { - port: defaultPorts.storagePort, - dataDir: "/tmp/supabase/storage", - fileSizeLimit: "50MiB", - s3ProtocolEnabled: true, - version: DEFAULT_VERSIONS.storage, - }, - imgproxy: { - port: defaultPorts.imgproxyPort, - version: DEFAULT_VERSIONS.imgproxy, - }, - }; - const { layer } = setupLayer(config); + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; + const resolver = mockBinaryResolver({ downloadedServices: ["postgrest"] }); + const { layer } = setupLayer(config, noopPortLease(config.ports), undefined, resolver); return Effect.gen(function* () { const stack = yield* Stack; const activator = yield* StackServiceActivator; yield* stack.start(); - yield* stack.stopService("imgproxy"); + yield* stack.stopService("postgres"); - const error = yield* activator.activate("storage").pipe(Effect.flip); + const downloading = yield* (yield* stack.stateChanges("postgrest")).pipe( + Stream.filter((state) => state.status === "Downloading"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const error = yield* activator.activate("postgrest").pipe(Effect.flip); + expect((yield* Fiber.join(downloading))._tag).toBe("Some"); expect(error._tag).toBe("StackBuildError"); if (error._tag === "StackBuildError") { - expect(error.detail).toContain("imgproxy was explicitly stopped"); + expect(error.detail).toContain("postgres was explicitly stopped"); + } + expect((yield* stack.getState("postgrest")).status).toBe("Dormant"); + yield* stack.stop(); + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); + }); + + it.live("preserves an explicit stop during in-flight lazy activation", () => { + const config = { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; + const allowDownload = Deferred.makeUnsafe(); + const resolver = mockBinaryResolver({ + downloadedServices: ["auth"], + beforeResolve: ({ service }) => + service === "auth" ? Deferred.await(allowDownload) : Effect.void, + }); + const { layer } = setupLayer(config, noopPortLease(config.ports), undefined, resolver); + + return Effect.gen(function* () { + const stack = yield* Stack; + const activator = yield* StackServiceActivator; + yield* stack.start(); + + const authChanges = yield* stack.stateChanges("auth"); + const downloading = yield* authChanges.pipe( + Stream.filter((state) => state.status === "Downloading"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const stopped = yield* authChanges.pipe( + Stream.filter((state) => state.status === "Stopped"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const activation = yield* activator + .activate("auth") + .pipe(Effect.forkChild({ startImmediately: true })); + + expect((yield* Fiber.join(downloading))._tag).toBe("Some"); + yield* stack.stopService("auth"); + expect((yield* Fiber.join(stopped))._tag).toBe("Some"); + yield* Deferred.succeed(allowDownload, undefined); + + const activationExit = yield* Fiber.await(activation); + expect(Exit.isFailure(activationExit)).toBe(true); + if (Exit.isFailure(activationExit)) { + const error = Cause.squash(activationExit.cause); + expect(error).toMatchObject({ _tag: "StackBuildError" }); + if (error instanceof StackBuildError) { + expect(error.detail).toContain("auth was explicitly stopped"); + } } + expect((yield* stack.getState("auth")).status).toBe("Stopped"); + yield* stack.stop(); + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); + }); + + it.live("rejects stopping a service before start without affecting a later start", () => { + const config = { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; + const { layer } = setupLayer(config); + + return Effect.gen(function* () { + const stack = yield* Stack; + const error = yield* stack.stopService("auth").pipe(Effect.flip); + + expect(error._tag).toBe("StackNotRunningError"); + if (error._tag === "StackNotRunningError") expect(error.phase).toBe("idle"); + + yield* stack.start(); + expect((yield* stack.getState("auth")).status).toBe("Dormant"); yield* stack.stop(); }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); @@ -729,7 +1249,15 @@ describe("Stack", () => { ? Deferred.succeed(spawnStarted, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void, }); - const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; + const config = { + ...defaultConfig, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "lazy", + auth: "lazy", + mailpit: "eager", + }, + } satisfies ResolvedStackConfig; const { layer } = setupLayer(config, noopPortLease(config.ports), spawner); yield* Effect.gen(function* () { @@ -775,7 +1303,10 @@ describe("Stack", () => { ? Deferred.succeed(authSpawnStarted, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void, }); - const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; + const config = { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; const { layer } = setupLayer(config, noopPortLease(config.ports), spawner); yield* Effect.gen(function* () { @@ -802,8 +1333,14 @@ describe("Stack", () => { const mailpitReleaseStarted = yield* Deferred.make(); const config = { ...defaultConfig, - mode: "auto", - startupMode: "lazy", + mode: "docker", + containerRuntime: "docker", + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "lazy", + auth: "lazy", + mailpit: "eager", + }, mailpit: { port: defaultPorts.mailpitPort, smtpPort: defaultPorts.mailpitSmtpPort, @@ -826,7 +1363,40 @@ describe("Stack", () => { : Effect.void, releaseAll: Effect.void, }; - const { layer } = setupLayer(config, lease); + const graph = Effect.runSync( + buildGraph([ + { + name: "postgres", + command: process.execPath, + restart: "unless-stopped", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + { + name: "mailpit", + command: process.execPath, + restart: "unless-stopped", + healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, + }, + ]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([ + ["postgres", { visibility: "public" as const }], + ["mailpit", { visibility: "public" as const }], + ]), + }), + }); + const resolver = mockBinaryResolver(); + const layer = localStackLayer(config, lease).pipe( + Layer.provide(builderLayer), + Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), + Layer.provide(mockChildProcessSpawner().layer), + Layer.provide(NodeServices.layer), + ); yield* Effect.gen(function* () { const stack = yield* Stack; @@ -835,7 +1405,10 @@ describe("Stack", () => { yield* Deferred.await(mailpitReleaseStarted); yield* Deferred.succeed(allowPostgresRelease, undefined); - yield* Fiber.interrupt(starting); + yield* Fiber.join(starting); + + expect((yield* stack.getState("postgres")).status).toBe("Healthy"); + expect((yield* stack.getState("mailpit")).status).toBe("Healthy"); yield* stack.stop(); }).pipe(Effect.provide(layer)); @@ -856,7 +1429,10 @@ describe("Stack", () => { ) : Effect.void, }); - const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; + const config = { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; const { layer } = setupLayer(config, noopPortLease(config.ports), spawner); yield* Effect.gen(function* () { @@ -878,58 +1454,67 @@ describe("Stack", () => { expect(spawner.spawned.some((record) => record.command.endsWith("/auth"))).toBe(false); const error = yield* activator.activate("auth").pipe(Effect.flip); - expect(error._tag).toBe("StackNotRunningError"); + expect(error._tag).toBe("StackBuildError"); + if (error._tag === "StackBuildError") { + expect(error.detail).toContain("disposal has begun"); + } }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.timeout("5 seconds")), ); - it.live("uses the stack readiness deadline for explicit lazy activation and cleans up", () => + it.live("dispose cancels in-flight lazy preparation", () => Effect.gen(function* () { - const spawner = mockChildProcessSpawner(); - let releasedAll = false; + const preparationStarted = yield* Deferred.make(); + const disposed = yield* Deferred.make(); + const resolver = mockBinaryResolver({ + downloadedServices: ["auth"], + beforeResolve: ({ service }) => + service === "auth" + ? Deferred.succeed(preparationStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + }); const config = { ...defaultConfig, - startupMode: "lazy", - readiness: { mode: "finite", timeoutMs: 100 }, - readinessSource: "configured", + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, } satisfies ResolvedStackConfig; - const lease: PortLease = { + const lease = { ...noopPortLease(config.ports), - releaseAll: Effect.sync(() => { - releasedAll = true; - }), - }; - const { layer } = setupLayer(config, lease, spawner); + releaseAll: Deferred.succeed(disposed, undefined).pipe(Effect.asVoid), + } satisfies PortLease; + const { layer } = setupLayer(config, lease, mockChildProcessSpawner(), resolver); yield* Effect.gen(function* () { const stack = yield* Stack; - const activator = yield* StackServiceActivator; yield* stack.start(); + const activation = yield* stack + .startService("auth") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(preparationStarted); + const secondActivation = yield* stack + .startService("auth") + .pipe(Effect.forkChild({ startImmediately: true })); - const error = yield* activator.activate("auth").pipe(Effect.flip); - - expect(error._tag).toBe("StackReadinessError"); - if (error._tag === "StackReadinessError") { - expect(error.target).toBe("auth"); - expect(error.timeoutMs).toBe(100); + const disposing = yield* stack.dispose().pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(disposed); + yield* Fiber.join(disposing); + + const activationExit = yield* Fiber.await(activation); + const secondActivationExit = yield* Fiber.await(secondActivation); + expect(Exit.isFailure(activationExit)).toBe(true); + expect(Exit.isFailure(secondActivationExit)).toBe(true); + if (Exit.isFailure(activationExit)) { + expect(Cause.squash(activationExit.cause)).toMatchObject({ + _tag: "StackBuildError", + detail: "Stack disposed during asset preparation", + }); } - expect(releasedAll).toBe(true); - const spawnCountAfterDisposal = spawner.spawned.length; - expect((yield* activator.activate("postgres").pipe(Effect.flip))._tag).toBe( - "StackNotRunningError", - ); - for (const operation of [ - stack.start(), - stack.startService("postgres"), - stack.stopService("postgres"), - stack.restartService("postgres"), - stack.reloadFunctions(), - stack.reloadEdgeRuntime({ edgeRuntime: {} }), - ]) { - expect((yield* operation.pipe(Effect.flip))._tag).toBe("StackBuildError"); + if (Exit.isFailure(secondActivationExit)) { + expect(Cause.squash(secondActivationExit.cause)).toMatchObject({ + _tag: "StackBuildError", + detail: "Stack disposed during asset preparation", + }); } - yield* stack.stop(); - expect(spawner.spawned).toHaveLength(spawnCountAfterDisposal); + expect((yield* stack.getState("auth")).status).not.toBe("Downloading"); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.timeout("5 seconds")), ); @@ -939,35 +1524,85 @@ describe("Stack", () => { const spawnStarted = yield* Deferred.make(); const spawner = mockChildProcessSpawner({ beforeSpawn: (record) => - record.args.some((arg) => - Buffer.from(arg, "base64url").toString().includes('"command":"/cache/auth/'), - ) - ? Deferred.succeed(spawnStarted, undefined).pipe(Effect.andThen(Effect.never)) + record.command === "/cache/auth" + ? Deferred.succeed(spawnStarted, undefined) : Effect.void, }); let releasedAll = false; const config = { ...defaultConfig, - startupMode: "lazy", + postgrest: false, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "off", auth: "lazy" }, readiness: { mode: "infinite" }, readinessSource: "configured", } satisfies ResolvedStackConfig; + const graph = Effect.runSync( + buildGraph([ + { + name: "postgres", + command: "true", + restart: "no", + }, + { + name: "auth", + command: "/cache/auth", + dependencies: [{ service: "postgres", condition: "started" }], + restart: "unless-stopped", + healthCheck: { + probe: { + _tag: "Http", + host: "127.0.0.1", + port: 1, + path: "/health", + scheme: "http", + }, + periodSeconds: 10, + }, + hooks: [{ on: "started", run: (log) => log("stderr", "auth startup failed") }], + }, + ]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([ + ["postgres", { visibility: "public" as const }], + ["auth", { visibility: "public" as const }], + ]), + }), + }); const lease: PortLease = { ...noopPortLease(config.ports), releaseAll: Effect.sync(() => { releasedAll = true; }), }; - const { layer } = setupLayer(config, lease, spawner); + const resolver = mockBinaryResolver(); + const layer = localStackLayer(config, lease).pipe( + Layer.provide(builderLayer), + Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), + Layer.provide(spawner.layer), + Layer.provide(NodeServices.layer), + ); yield* Effect.gen(function* () { const stack = yield* Stack; const activator = yield* StackServiceActivator; yield* stack.start(); + const authLog = yield* stack + .subscribeLogs("auth") + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); const activation = yield* activator .activate("auth") .pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(spawnStarted); + const authLogEntry = yield* Fiber.join(authLog); + expect(authLogEntry).toMatchObject({ + _tag: "Some", + value: { line: "auth startup failed", service: "auth" }, + }); const error = yield* stack .waitAllReady({ mode: "finite", timeoutMs: 25 }) @@ -977,6 +1612,9 @@ describe("Stack", () => { if (error._tag === "StackReadinessError") { expect(error.target).toBe("stack"); expect(error.timeoutMs).toBe(25); + expect(error.detail).toContain("Non-ready services: auth:"); + expect(error.detail).toContain("Recent logs"); + expect(error.detail).toContain("auth startup failed"); } expect(releasedAll).toBe(true); yield* Fiber.interrupt(activation); @@ -987,15 +1625,22 @@ describe("Stack", () => { it.live("does not revive stopped lazy dependents when restarting a dependency", () => { return Effect.gen(function* () { const authHealthServer = yield* Effect.acquireRelease( - Effect.sync(() => - Bun.serve({ - port: 0, - fetch: () => new Response("ok"), - }), + Effect.tryPromise( + () => + new Promise((resolve, reject) => { + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/plain" }); + response.end("ok"); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), ), - (server) => Effect.sync(() => server.stop(true)), + (server) => + Effect.tryPromise(() => new Promise((resolve) => server.close(() => resolve()))), ); - const authPort = authHealthServer.port; + const address = authHealthServer.address(); + const authPort = typeof address === "object" && address !== null ? address.port : undefined; if (authPort === undefined) { throw new Error("Expected the auth health test server to bind a TCP port"); } @@ -1005,7 +1650,7 @@ describe("Stack", () => { } const { layer, spawner } = setupLayer({ ...defaultConfig, - startupMode: "lazy", + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, ports: { ...defaultPorts, authPort }, auth: { ...authConfig, port: authPort }, }); @@ -1033,7 +1678,10 @@ describe("Stack", () => { }); it.live("lazy readiness fails fast before a service is activated", () => { - const { layer } = setupLayer({ ...defaultConfig, startupMode: "lazy" }); + const { layer } = setupLayer({ + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + }); return Effect.gen(function* () { const stack = yield* Stack; @@ -1048,8 +1696,88 @@ describe("Stack", () => { }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); + it.live( + "restores dormant lazy services after preparation failure and retries successfully", + () => { + return Effect.gen(function* () { + const healthServer = yield* Effect.acquireRelease( + Effect.tryPromise( + () => + new Promise((resolve, reject) => { + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/plain" }); + response.end("ok"); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), + ), + (server) => + Effect.tryPromise(() => new Promise((resolve) => server.close(() => resolve()))), + ); + const address = healthServer.address(); + const postgrestPort = typeof address === "object" && address !== null ? address.port : 0; + if (postgrestPort === 0) throw new Error("Expected a PostgREST health port"); + const basePostgrest = defaultConfig.postgrest; + if (basePostgrest === false) throw new Error("Expected PostgREST in the default config"); + const config = { + ...defaultConfig, + servicePolicies: { + ...defaultConfig.servicePolicies, + postgrest: "lazy", + auth: "off", + }, + auth: false, + ports: { + ...defaultConfig.ports, + postgrestPort, + postgrestAdminPort: postgrestPort + 1, + }, + postgrest: { + ...basePostgrest, + port: postgrestPort, + adminPort: postgrestPort + 1, + }, + } satisfies ResolvedStackConfig; + const failingResolver = mockBinaryResolver({ failOnceServices: ["postgrest"] }); + const stackPreparationLayer = StackPreparation.layer.pipe( + Layer.provide(failingResolver.layer), + ); + const testLayer = localStackLayer(config, noopPortLease(config.ports)).pipe( + Layer.provide(StackBuilder.layer), + Layer.provide(stackPreparationLayer), + Layer.provide(mockChildProcessSpawner().layer), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const stack = yield* Stack; + const activator = yield* StackServiceActivator; + yield* stack.start(); + expect(["Running", "Healthy", "Initializing"]).toContain( + (yield* stack.getState("postgres")).status, + ); + expect((yield* stack.getState("postgrest")).status).toBe("Dormant"); + const first = yield* activator.activate("postgrest").pipe(Effect.flip); + expect(first._tag).toBe("StackBuildError"); + expect(["Running", "Healthy", "Initializing"]).toContain( + (yield* stack.getState("postgres")).status, + ); + expect((yield* stack.getState("postgrest")).status).toBe("Dormant"); + + yield* activator.activate("postgrest"); + expect(["Running", "Healthy"]).toContain((yield* stack.getState("postgrest")).status); + yield* stack.stop(); + }).pipe(Effect.provide(testLayer)); + }).pipe(Effect.scoped, Effect.timeout("5 seconds")); + }, + ); + it.live("keeps unactivated services dormant after a stop and start cycle", () => { - const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; + const config = { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; const { layer } = setupLayer(config); return Effect.gen(function* () { @@ -1066,7 +1794,10 @@ describe("Stack", () => { }); it.live("rejects a cached activation after the stack has stopped", () => { - const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; + const config = { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; const { layer } = setupLayer(config); return Effect.gen(function* () { @@ -1081,7 +1812,10 @@ describe("Stack", () => { }); it.live("preserves an explicitly stopped service across a stack restart", () => { - const config = { ...defaultConfig, startupMode: "lazy" } satisfies ResolvedStackConfig; + const config = { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + } satisfies ResolvedStackConfig; const { layer } = setupLayer(config); return Effect.gen(function* () { @@ -1115,7 +1849,13 @@ describe("Stack", () => { }), releaseAll: Effect.void, }; - const { layer } = setupLayer({ ...defaultConfig, startupMode: "lazy" }, lease); + const { layer } = setupLayer( + { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, + }, + lease, + ); return Effect.gen(function* () { const stack = yield* Stack; diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index b45e351f1b..4947c1be4b 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -15,7 +15,10 @@ import { makeImgproxyServiceDocker } from "./services/imgproxy.ts"; import { makeMailpitServiceDocker } from "./services/mailpit.ts"; import { makePgmetaServiceDocker } from "./services/pgmeta.ts"; import { makePoolerServiceDocker } from "./services/pooler.ts"; -import { makePostgresInitService } from "./services/postgres-init.ts"; +import { + makePostgresInitService, + makePostgresInitServiceDocker, +} from "./services/postgres-init.ts"; import { makePostgresService, makePostgresServiceDocker } from "./services/postgres.ts"; import { makePostgrestService, makePostgrestServiceDocker } from "./services/postgrest.ts"; import { makeRealtimeServiceDocker } from "./services/realtime.ts"; @@ -48,6 +51,9 @@ export interface BuildResult { const dockerOnlyServices = SERVICE_NAMES.filter( (service) => serviceMetadata(service).runtimeSupport === "docker-only", ); +const nativeServices = SERVICE_NAMES.filter( + (service) => serviceMetadata(service).runtimeSupport !== "docker-only", +); // Serial health-check paths used by dependency waits; keep each path aligned // with the corresponding service's transitive dependencies. @@ -57,14 +63,12 @@ const analyticsStartupPath: ReadonlyArray = ["postgres", "analytics const postgresDependencyTimeoutSeconds = dependencyTimeoutSecondsForServices(postgresStartupPath); -const dependsOnPostgres = (hasPostgresInit: boolean): ReadonlyArray => - hasPostgresInit - ? [{ service: "postgres-init", condition: "completed" }] - : [{ service: "postgres", condition: "healthy" }]; +const postgresDependencies: ReadonlyArray = [ + { service: "postgres-init", condition: "completed" }, +]; const publicServiceProjection = ( defs: ReadonlyArray, - hasPostgresInit: boolean, ): StackServiceProjectionCatalog => { const serviceProjection: Map< string, @@ -75,13 +79,11 @@ const publicServiceProjection = ( } > = new Map(defs.map((def) => [def.name, { visibility: "public" as const }] as const)); - if (hasPostgresInit) { - serviceProjection.set("postgres-init", { - visibility: "internal", - owner: "postgres", - ownerStatusWhileActive: "Initializing", - }); - } + serviceProjection.set("postgres-init", { + visibility: "internal", + owner: "postgres", + ownerStatusWhileActive: "Initializing", + }); return serviceProjection; }; @@ -101,6 +103,17 @@ export const validateResolvedConfig = ( config: ResolvedStackConfig, ): Effect.Effect => Effect.gen(function* () { + if ( + (config.mode === "native" && config.containerRuntime !== null) || + (config.mode === "docker" && config.containerRuntime === null) + ) { + return yield* Effect.fail( + new StackBuildError({ + detail: `Resolved ${config.mode} mode has an inconsistent container runtime`, + reason: "invalid_config", + }), + ); + } if (config.instanceId !== undefined && !INSTANCE_ID_PATTERN.test(config.instanceId)) { return yield* Effect.fail( new StackBuildError({ @@ -117,7 +130,7 @@ export const validateResolvedConfig = ( if (enabledDockerOnly.length > 0) { return yield* Effect.fail( new StackBuildError({ - detail: `mode "native" only supports postgres, auth, and postgrest. Disable ${enabledDockerOnly.join(", ")} or switch to "auto" or "docker".`, + detail: `Native mode supports only ${nativeServices.join(", ")}. Disable ${enabledDockerOnly.join(", ")} or select Docker mode with a usable Docker or Podman runtime.`, reason: "invalid_config", }), ); @@ -154,7 +167,9 @@ export const validateResolvedConfig = ( export const enabledServicesForConfig = (config: ResolvedStackConfig): ReadonlyArray => SERVICE_NAMES.filter( - (service) => service === "postgres" || resolvedConfigForService(config, service) !== false, + (service) => + config.servicePolicies?.[service] !== "off" && + resolvedConfigForService(config, service) !== false, ); export const versionsForConfig = (config: ResolvedStackConfig): Partial => { @@ -198,11 +213,6 @@ const requirePreparedDockerImage = ( ), ); -export const nativePostgresNeedsDockerAccess = ( - postgresResolution: ServiceResolution, - dockerServicesEnabled: boolean, -): boolean => postgresResolution.type === "binary" && dockerServicesEnabled; - export class StackBuilder extends Context.Service< StackBuilder, { @@ -217,6 +227,17 @@ export class StackBuilder extends Context.Service< Effect.gen(function* () { yield* validateResolvedConfig(config); + const requireContainerRuntime = Effect.suspend(() => + config.containerRuntime === null + ? Effect.fail( + new StackBuildError({ + detail: "A Docker service requires a selected container runtime", + reason: "invalid_config", + }), + ) + : Effect.succeed(config.containerRuntime), + ); + const platform = yield* detectPlatform; const serviceHost = dockerHostAddress(platform.os); const projectDir = config.projectDir; @@ -236,29 +257,7 @@ export class StackBuilder extends Context.Service< ? false : yield* requirePreparedResolution(prepared, "postgrest"); - const dockerServicesEnabled = - config.realtime !== false || - config.storage !== false || - config.imgproxy !== false || - config.mailpit !== false || - config.pgmeta !== false || - config.studio !== false || - config.analytics !== false || - config.vector !== false || - config.pooler !== false || - (edgeRuntimeResolution !== false && edgeRuntimeResolution.type === "docker") || - (authResolution !== false && authResolution.type === "docker") || - (postgrestResolution !== false && postgrestResolution.type === "docker"); - - const needsDockerAccess = nativePostgresNeedsDockerAccess( - postgresResolution, - dockerServicesEnabled, - ); - const hasPostgresInit = postgresResolution.type === "binary"; - const postgresDeps = dependsOnPostgres(hasPostgresInit); - const postgresInitCompletionBudgetSeconds = hasPostgresInit - ? POSTGRES_INIT_COMPLETION_BUDGET_SECONDS - : 0; + const postgresInitCompletionBudgetSeconds = POSTGRES_INIT_COMPLETION_BUDGET_SECONDS; const postgresConsumerDependencyTimeoutSeconds = postgresDependencyTimeoutSeconds + postgresInitCompletionBudgetSeconds; const storageDependencyTimeoutSeconds = @@ -277,17 +276,15 @@ export class StackBuilder extends Context.Service< binPath: postgresResolution.path, dataDir: config.postgres.dataDir, port: config.dbPort, - dockerAccessible: needsDockerAccess, cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), dependencies: [], }) : makePostgresServiceDocker({ + runtime: yield* requireContainerRuntime, image: postgresResolution.image, dataDir: config.postgres.dataDir, port: config.dbPort, platformOs: platform.os, - jwtSecret: config.jwtSecret, - jwtExpiry: config.auth !== false ? config.auth.jwtExpiry : 3600, identity, cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), dependencies: [], @@ -296,18 +293,26 @@ export class StackBuilder extends Context.Service< }, ]; - if (hasPostgresInit) { - defs.push({ - ...makePostgresInitService({ - postgresDir: postgresResolution.path, - dbPort: config.dbPort, - autoExposeNewTables: config.postgres.autoExposeNewTables, - dependencies: [{ service: "postgres", condition: "healthy" }], - }), - dependencyTimeoutSeconds: postgresDependencyTimeoutSeconds, - enabled: true, - }); - } + defs.push({ + ...(postgresResolution.type === "binary" + ? makePostgresInitService({ + postgresDir: postgresResolution.path, + dbPort: config.dbPort, + autoExposeNewTables: config.postgres.autoExposeNewTables, + dependencies: [{ service: "postgres", condition: "healthy" }], + }) + : makePostgresInitServiceDocker({ + runtime: yield* requireContainerRuntime, + dbPort: config.dbPort, + jwtSecret: config.jwtSecret, + jwtExpiry: config.auth !== false ? config.auth.jwtExpiry : 3600, + autoExposeNewTables: config.postgres.autoExposeNewTables, + identity, + dependencies: [{ service: "postgres", condition: "healthy" }], + })), + dependencyTimeoutSeconds: postgresDependencyTimeoutSeconds, + enabled: true, + }); if (config.postgrest !== false && postgrestResolution !== false) { defs.push({ @@ -320,9 +325,10 @@ export class StackBuilder extends Context.Service< extraSearchPath: config.postgrest.extraSearchPath, maxRows: config.postgrest.maxRows, jwtSecret: config.jwtSecret, - dependencies: postgresDeps, + dependencies: postgresDependencies, }) : makePostgrestServiceDocker({ + runtime: yield* requireContainerRuntime, image: postgrestResolution.image, dbHost: serviceHost, dbPort: config.dbPort, @@ -334,7 +340,7 @@ export class StackBuilder extends Context.Service< jwtSecret: config.jwtSecret, platformOs: platform.os, identity, - dependencies: postgresDeps, + dependencies: postgresDependencies, })), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, @@ -356,9 +362,10 @@ export class StackBuilder extends Context.Service< smtpPort: config.mailpit !== false ? config.mailpit.smtpPort : undefined, smtpAdminEmail: config.mailpit !== false ? config.mailpit.adminEmail : undefined, smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, - dependencies: postgresDeps, + dependencies: postgresDependencies, }) : makeAuthServiceDocker({ + runtime: yield* requireContainerRuntime, image: authResolution.image, dbHost: serviceHost, dbPort: config.dbPort, @@ -373,7 +380,7 @@ export class StackBuilder extends Context.Service< smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, platformOs: platform.os, identity, - dependencies: postgresDeps, + dependencies: postgresDependencies, })), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, @@ -390,9 +397,10 @@ export class StackBuilder extends Context.Service< inspectorPort: config.edgeRuntime.inspectorPort, policy: config.edgeRuntime.policy, env: config.edgeRuntime.env, - dependencies: postgresDeps, + dependencies: postgresDependencies, }) : makeEdgeRuntimeServiceDocker({ + runtime: yield* requireContainerRuntime, image: edgeRuntimeResolution.image, identity, runtimeRoot: config.runtimeRoot, @@ -402,7 +410,7 @@ export class StackBuilder extends Context.Service< policy: config.edgeRuntime.policy, env: config.edgeRuntime.env, platformOs: platform.os, - dependencies: postgresDeps, + dependencies: postgresDependencies, })), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, @@ -413,6 +421,7 @@ export class StackBuilder extends Context.Service< const mailpitImage = yield* requirePreparedDockerImage(prepared, "mailpit"); defs.push({ ...makeMailpitServiceDocker({ + runtime: yield* requireContainerRuntime, image: mailpitImage, identity, webPort: config.mailpit.port, @@ -429,6 +438,7 @@ export class StackBuilder extends Context.Service< const realtimeImage = yield* requirePreparedDockerImage(prepared, "realtime"); defs.push({ ...makeRealtimeServiceDocker({ + runtime: yield* requireContainerRuntime, image: realtimeImage, port: config.realtime.port, identity, @@ -441,7 +451,7 @@ export class StackBuilder extends Context.Service< secretKeyBase: config.realtime.secretKeyBase, maxHeaderLength: config.realtime.maxHeaderLength, platformOs: platform.os, - dependencies: postgresDeps, + dependencies: postgresDependencies, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, @@ -452,6 +462,7 @@ export class StackBuilder extends Context.Service< const storageImage = yield* requirePreparedDockerImage(prepared, "storage"); defs.push({ ...makeStorageServiceDocker({ + runtime: yield* requireContainerRuntime, image: storageImage, port: config.storage.port, identity, @@ -468,7 +479,7 @@ export class StackBuilder extends Context.Service< config.imgproxy !== false ? `http://${serviceHost}:${config.imgproxy.port}` : "", s3ProtocolEnabled: config.storage.s3ProtocolEnabled, platformOs: platform.os, - dependencies: postgresDeps, + dependencies: postgresDependencies, cleanupDataDirOnExit: hasAutoManagedPath(config, config.storage.dataDir), }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, @@ -481,6 +492,7 @@ export class StackBuilder extends Context.Service< const imgproxyImage = yield* requirePreparedDockerImage(prepared, "imgproxy"); defs.push({ ...makeImgproxyServiceDocker({ + runtime: yield* requireContainerRuntime, image: imgproxyImage, port: config.imgproxy.port, identity, @@ -497,13 +509,14 @@ export class StackBuilder extends Context.Service< const pgmetaImage = yield* requirePreparedDockerImage(prepared, "pgmeta"); defs.push({ ...makePgmetaServiceDocker({ + runtime: yield* requireContainerRuntime, image: pgmetaImage, identity, port: config.pgmeta.port, dbHost: serviceHost, dbPort: config.dbPort, platformOs: platform.os, - dependencies: postgresDeps, + dependencies: postgresDependencies, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, @@ -514,6 +527,7 @@ export class StackBuilder extends Context.Service< const analyticsImage = yield* requirePreparedDockerImage(prepared, "analytics"); defs.push({ ...makeAnalyticsServiceDocker({ + runtime: yield* requireContainerRuntime, image: analyticsImage, identity, hostPort: config.analytics.port, @@ -522,7 +536,7 @@ export class StackBuilder extends Context.Service< dbPort: config.dbPort, apiKey: config.analytics.apiKey, backend: config.analytics.backend, - dependencies: postgresDeps, + dependencies: postgresDependencies, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, @@ -534,6 +548,7 @@ export class StackBuilder extends Context.Service< const vectorImage = yield* requirePreparedDockerImage(prepared, "vector"); defs.push({ ...makeVectorServiceDocker({ + runtime: yield* requireContainerRuntime, image: vectorImage, identity, serviceHost, @@ -551,6 +566,7 @@ export class StackBuilder extends Context.Service< const poolerImage = yield* requirePreparedDockerImage(prepared, "pooler"); defs.push({ ...makePoolerServiceDocker({ + runtime: yield* requireContainerRuntime, image: poolerImage, identity, hostAdminPort: config.pooler.apiPort, @@ -565,7 +581,7 @@ export class StackBuilder extends Context.Service< tenantId: config.pooler.tenantId, encryptionKey: config.pooler.encryptionKey, secretKeyBase: config.pooler.secretKeyBase, - dependencies: postgresDeps, + dependencies: postgresDependencies, }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, @@ -577,6 +593,7 @@ export class StackBuilder extends Context.Service< const studioImage = yield* requirePreparedDockerImage(prepared, "studio"); defs.push({ ...makeStudioServiceDocker({ + runtime: yield* requireContainerRuntime, image: studioImage, identity, port: config.studio.port, @@ -608,7 +625,12 @@ export class StackBuilder extends Context.Service< } const dockerContainerNames = SERVICE_NAMES.filter((service) => - defs.some((def) => def.name === service && def.command === "docker"), + defs.some( + (def) => + def.name === service && + config.containerRuntime !== null && + def.command === config.containerRuntime, + ), ).map((service) => dockerContainerName(service, identity.key)); const graph = yield* buildGraph(defs).pipe( @@ -626,7 +648,7 @@ export class StackBuilder extends Context.Service< cleanupTargets: { dockerContainerNames, }, - serviceProjection: publicServiceProjection(defs, hasPostgresInit), + serviceProjection: publicServiceProjection(defs), }; }), }); diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index 84cbe83e3d..285d27595a 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -9,7 +9,6 @@ import type { BuildResult } from "./StackBuilder.ts"; import { DEFAULT_STACK_READINESS_POLICY, type ResolvedStackConfig } from "./StackConfig.ts"; import { STACK_ID_LABEL } from "./StackIdentity.ts"; import { enabledServicesForConfig, versionsForConfig } from "./StackBuilder.ts"; -import { nativePostgresNeedsDockerAccess } from "./StackBuilder.ts"; import type { AllocatedPorts } from "./PortCatalog.ts"; import { StackPreparation } from "./StackPreparation.ts"; import type { StackPreparationInput } from "./StackPreparation.ts"; @@ -47,8 +46,23 @@ const baseConfig: ResolvedStackConfig = { stackRoot: "/tmp/supabase-stack", runtimeRoot: "/tmp/supabase-runtime", projectDir: "/tmp/supabase-project", - mode: "auto", - startupMode: "eager", + mode: "native", + containerRuntime: null, + servicePolicies: { + postgres: "eager", + postgrest: "eager", + auth: "eager", + "edge-runtime": "off", + realtime: "off", + storage: "off", + imgproxy: "off", + mailpit: "off", + pgmeta: "off", + studio: "off", + analytics: "off", + vector: "off", + pooler: "off", + }, readiness: DEFAULT_STACK_READINESS_POLICY, readinessSource: "default", jwtSecret: testJwtSecret, @@ -97,6 +111,7 @@ const baseConfig: ResolvedStackConfig = { const dockerConfig: ResolvedStackConfig = { ...baseConfig, mode: "docker", + containerRuntime: "docker", }; /** @@ -119,7 +134,9 @@ const siblingManagedConfig: ResolvedStackConfig = { const edgeRuntimeConfig: ResolvedStackConfig = { ...baseConfig, - mode: "auto", + mode: "docker", + containerRuntime: "docker", + servicePolicies: { ...baseConfig.servicePolicies, "edge-runtime": "eager" }, edgeRuntime: { enabled: true, port: basePorts.edgeRuntimePort, @@ -181,28 +198,21 @@ const prepareAndBuild = ( config: ResolvedStackConfig, ): Effect.Effect => Effect.gen(function* () { - const input: StackPreparationInput = { - mode: config.mode, + const shared = { services: enabledServicesForConfig(config), versions: versionsForConfig(config), }; + const input: StackPreparationInput = + config.mode === "native" + ? { ...shared, mode: "native" } + : config.containerRuntime === null + ? yield* Effect.die("Docker test config is missing its container runtime") + : { ...shared, mode: "docker", containerRuntime: config.containerRuntime }; const prepared = yield* preparation.prepare(input); return yield* builder.build(config, prepared); }); describe("StackBuilder", () => { - it("makes native postgres reachable by docker services on every platform", () => { - expect(nativePostgresNeedsDockerAccess({ type: "binary", path: "/cache/postgres" }, true)).toBe( - true, - ); - expect( - nativePostgresNeedsDockerAccess({ type: "binary", path: "/cache/postgres" }, false), - ).toBe(false); - expect( - nativePostgresNeedsDockerAccess({ type: "docker", image: "supabase/postgres" }, true), - ).toBe(false); - }); - it.effect("builds graph with all native binaries", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); @@ -255,70 +265,6 @@ describe("StackBuilder", () => { }).pipe(Effect.provide(layer)); }); - it.effect("uses docker fallback when auth binary not found", () => { - const resolver = mockBinaryResolver({ failServices: ["auth"] }); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph } = yield* prepareAndBuild(builder, preparation, baseConfig); - - expect(graph.startOrder.length).toBe(4); - - const authDef = graph.startOrder.find((s) => s.name === "auth"); - expect(authDef).toBeDefined(); - expect(authDef?.command).toBe("docker"); - expect(authDef?.dependencies).toEqual([{ service: "postgres-init", condition: "completed" }]); - expect(authDef?.supervision).toBeDefined(); - }).pipe(Effect.provide(layer)); - }); - - it.effect("uses docker fallback when postgres binary not found", () => { - const resolver = mockBinaryResolver({ failServices: ["postgres"] }); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph } = yield* prepareAndBuild(builder, preparation, baseConfig); - - // No postgres-init when postgres falls back to Docker. - expect(graph.startOrder.length).toBe(3); - - const postgresDef = graph.startOrder.find((s) => s.name === "postgres"); - expect(postgresDef).toBeDefined(); - expect(postgresDef?.command).toBe("docker"); - expect(postgresDef?.supervision).toBeDefined(); - - // postgrest falls back to postgres(healthy) dependency - const postgrestDef = graph.startOrder.find((s) => s.name === "postgrest"); - expect(postgrestDef?.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); - - const names = graph.startOrder.map((s) => s.name); - expect(names).not.toContain("postgres-init"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("uses docker fallback when postgrest binary not found", () => { - const resolver = mockBinaryResolver({ failServices: ["postgrest"] }); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph } = yield* prepareAndBuild(builder, preparation, baseConfig); - - // All 4 services still present (postgrest falls back to Docker, not removed) - expect(graph.startOrder.length).toBe(4); - - const postgrestDef = graph.startOrder.find((s) => s.name === "postgrest"); - expect(postgrestDef).toBeDefined(); - expect(postgrestDef?.command).toBe("docker"); - expect(postgrestDef?.supervision).toBeDefined(); - }).pipe(Effect.provide(layer)); - }); - it.effect("excludes disabled services", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); @@ -341,20 +287,21 @@ describe("StackBuilder", () => { }).pipe(Effect.provide(layer)); }); - it.effect("docker mode produces Docker service defs for all services", () => { + it.effect("Docker mode consistently uses the selected container runtime", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); return Effect.gen(function* () { const builder = yield* StackBuilder; const preparation = yield* StackPreparation; - const { graph, cleanupTargets } = yield* prepareAndBuild(builder, preparation, dockerConfig); + const config = { ...dockerConfig, containerRuntime: "podman" } satisfies ResolvedStackConfig; + const { graph, cleanupTargets } = yield* prepareAndBuild(builder, preparation, config); - expect(graph.startOrder.length).toBe(3); + expect(graph.startOrder.length).toBe(4); const names = graph.startOrder.map((s) => s.name); expect(names).toContain("postgres"); - expect(names).not.toContain("postgres-init"); + expect(names).toContain("postgres-init"); expect(names).toContain("postgrest"); expect(names).toContain("auth"); @@ -363,15 +310,18 @@ describe("StackBuilder", () => { for (const name of ["postgres", "postgrest", "auth"]) { const def = graph.startOrder.find((s) => s.name === name); expect(def).toBeDefined(); - expect(def?.command).toBe("docker"); + expect(def?.command).toBe("podman"); expect(def?.supervision).toBeDefined(); + expect(def?.supervision?.orphanCleanup).toContainEqual( + expect.objectContaining({ executable: "podman" }), + ); } // Docker container names are collected for cleanup expect(cleanupTargets.dockerContainerNames).toEqual([ - `supabase-postgres-${dockerConfig.apiPort}`, - `supabase-postgrest-${dockerConfig.apiPort}`, - `supabase-auth-${dockerConfig.apiPort}`, + `supabase-postgres-${config.apiPort}`, + `supabase-postgrest-${config.apiPort}`, + `supabase-auth-${config.apiPort}`, ]); }).pipe(Effect.provide(layer)); }); @@ -399,7 +349,7 @@ describe("StackBuilder", () => { expect(name).not.toContain(String(managedConfig.apiPort)); } - for (const def of graph.startOrder) { + for (const def of graph.startOrder.filter((service) => service.args?.[0] === "run")) { expect(def.args).toContain(`supabase-${def.name}-id-${firstManagedId}`); // The label carries the whole identity, so the containers stay findable // by it even if the names are ever built differently. @@ -457,14 +407,14 @@ describe("StackBuilder", () => { `supabase-postgrest-${dockerConfig.apiPort}`, `supabase-auth-${dockerConfig.apiPort}`, ]); - for (const def of graph.startOrder) { + for (const def of graph.startOrder.filter((service) => service.args?.[0] === "run")) { expect(def.args).toContain(`supabase-${def.name}-${dockerConfig.apiPort}`); expect(def.args?.join(" ")).not.toContain(STACK_ID_LABEL); } }).pipe(Effect.provide(layer)); }); - it.effect("docker mode wires auth directly to postgres readiness", () => { + it.effect("docker consumers wait for database initialization", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); @@ -474,27 +424,13 @@ describe("StackBuilder", () => { const { graph } = yield* prepareAndBuild(builder, preparation, dockerConfig); const authDef = graph.startOrder.find((s) => s.name === "auth"); - expect(authDef?.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); + expect(authDef?.dependencies).toEqual([{ service: "postgres-init", condition: "completed" }]); expect(authDef?.dependencyTimeoutSeconds).toBe( - dependencyTimeoutSecondsForServices(["postgres"]), + dependencyTimeoutSecondsForServices(["postgres"]) + POSTGRES_INIT_COMPLETION_BUDGET_SECONDS, ); }).pipe(Effect.provide(layer)); }); - it.effect("docker mode has no postgres-init service for Docker postgres", () => { - const resolver = mockBinaryResolver(); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph } = yield* prepareAndBuild(builder, preparation, dockerConfig); - - const names = graph.startOrder.map((s) => s.name); - expect(names).not.toContain("postgres-init"); - }).pipe(Effect.provide(layer)); - }); - it.effect("docker mode wires dependencies correctly", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); @@ -505,41 +441,17 @@ describe("StackBuilder", () => { const { graph } = yield* prepareAndBuild(builder, preparation, dockerConfig); const authDef = graph.startOrder.find((s) => s.name === "auth"); - expect(authDef?.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); + expect(authDef?.dependencies).toEqual([{ service: "postgres-init", condition: "completed" }]); - // postgrest depends on postgres(healthy) — no postgres-init in Docker mode const postgrestDef = graph.startOrder.find((s) => s.name === "postgrest"); - expect(postgrestDef?.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("uses docker-backed edge-runtime even when a native binary is available", () => { - const resolver = mockBinaryResolver(); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph, cleanupTargets } = yield* prepareAndBuild( - builder, - preparation, - edgeRuntimeConfig, - ); - - const edgeRuntimeDef = graph.startOrder.find((service) => service.name === "edge-runtime"); - expect(edgeRuntimeDef).toBeDefined(); - expect(edgeRuntimeDef?.command).toBe("docker"); - expect(edgeRuntimeDef?.dependencies).toEqual([ + expect(postgrestDef?.dependencies).toEqual([ { service: "postgres-init", condition: "completed" }, ]); - expect(cleanupTargets.dockerContainerNames).toContain( - `supabase-edge-runtime-${edgeRuntimeConfig.apiPort}`, - ); }).pipe(Effect.provide(layer)); }); - it.effect("uses docker-backed edge-runtime when the binary is unavailable", () => { - const resolver = mockBinaryResolver({ failServices: ["edge-runtime"] }); + it.effect("uses Docker for edge-runtime and its dependencies in Docker mode", () => { + const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); return Effect.gen(function* () { @@ -562,38 +474,4 @@ describe("StackBuilder", () => { ); }).pipe(Effect.provide(layer)); }); - - it.effect("falls back to the next registry for docker-only services", () => { - const resolver = mockBinaryResolver(); - const spawnerLayer = mockSequenceSpawner([ - { exitCode: 0 }, - { exitCode: 0 }, - { exitCode: 0 }, - { exitCode: 1, stderr: ["manifest unknown"] }, - { exitCode: 0 }, - ]); - const layer = builderLayer(resolver, spawnerLayer); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph } = yield* prepareAndBuild(builder, preparation, { - ...dockerConfig, - realtime: { - port: 3010, - version: DEFAULT_VERSIONS.realtime, - tenantId: "realtime-dev", - encryptionKey: "supabaserealtime", - secretKeyBase: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", - maxHeaderLength: 4096, - }, - }); - - const realtimeDef = graph.startOrder.find((service) => service.name === "realtime"); - expect(realtimeDef?.args).toContain(`supabase/realtime:v${DEFAULT_VERSIONS.realtime}`); - expect(realtimeDef?.args).not.toContain( - `public.ecr.aws/supabase/realtime:v${DEFAULT_VERSIONS.realtime}`, - ); - }).pipe(Effect.provide(layer)); - }); }); diff --git a/packages/stack/src/StackConfig.ts b/packages/stack/src/StackConfig.ts index 25ba352931..13694f9747 100644 --- a/packages/stack/src/StackConfig.ts +++ b/packages/stack/src/StackConfig.ts @@ -1,9 +1,13 @@ import { Schema } from "effect"; import type { ResolvedFunctionsBundle } from "./functions.ts"; import type { ResolvedPorts } from "./PortCatalog.ts"; +import type { ServiceName } from "./ServiceName.ts"; -type StackMode = "native" | "auto" | "docker"; -type StackStartupMode = "eager" | "lazy"; +import type { ContainerRuntime } from "./ContainerRuntime.ts"; + +export type StackMode = "native" | "docker"; +export type ServicePolicy = "off" | "lazy" | "eager"; +export type ServicePolicyManifest = Readonly>; export type ReadinessPolicy = | { readonly mode: "finite"; readonly timeoutMs: number } @@ -174,8 +178,8 @@ export interface StackConfig { readonly runtimeRoot?: string; readonly projectDir?: string; readonly mode?: StackMode; - /** Start all services immediately, or defer proxied services until first use. */ - readonly startupMode?: StackStartupMode; + /** Per-service resource policy. `off` excludes a service from the graph. */ + readonly servicePolicies?: Partial>; /** Stack-wide readiness policy. Per-call ReadyOptions take precedence. */ readonly readiness?: ReadinessPolicy; readonly jwtSecret?: string; @@ -304,7 +308,9 @@ export interface ResolvedStackConfig { readonly runtimeRoot: string; readonly projectDir: string; readonly mode: StackMode; - readonly startupMode: StackStartupMode; + /** Concrete container executable selected once when the stack was created. */ + readonly containerRuntime: ContainerRuntime | null; + readonly servicePolicies: ServicePolicyManifest; readonly readiness: ReadinessPolicy; /** Whether readiness came from the package default or an explicit stack policy. */ readonly readinessSource: "default" | "configured"; diff --git a/packages/stack/src/StackConfigResolver.policy.unit.test.ts b/packages/stack/src/StackConfigResolver.policy.unit.test.ts new file mode 100644 index 0000000000..e2d39c80b0 --- /dev/null +++ b/packages/stack/src/StackConfigResolver.policy.unit.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { resolveConfig } from "./StackConfigResolver.ts"; +import { StackBuildError } from "./errors.ts"; + +describe("resolved service preparation policies", () => { + it("applies explicit policies and catalog defaults while keeping Postgres eager", async () => { + const config = await resolveConfig({ + servicePolicies: { postgrest: "eager", mailpit: "eager" }, + mailpit: {}, + stackRoot: "/tmp/stack-policy-test", + runtimeRoot: "/tmp/runtime-policy-test", + }); + + expect(config.servicePolicies.postgres).toBe("eager"); + expect(config.servicePolicies.postgrest).toBe("eager"); + expect(config.servicePolicies.auth).toBe("lazy"); + expect(config.servicePolicies.mailpit).toBe("eager"); + }); + + it("rejects an unsupported lazy policy before port allocation", async () => { + let allocated = false; + await expect( + resolveConfig( + { servicePolicies: { postgres: "lazy" } }, + { + portAllocator: () => { + allocated = true; + throw new Error("must not allocate"); + }, + }, + ), + ).rejects.toBeInstanceOf(StackBuildError); + expect(allocated).toBe(false); + }); + + it("rejects disabling postgres through the service policy manifest", async () => { + await expect(resolveConfig({ servicePolicies: { postgres: "off" } })).rejects.toMatchObject({ + _tag: "StackBuildError", + reason: "invalid_config", + }); + }); + + it("resolves explicitly disabled core services to false without reserving ports", async () => { + const config = await resolveConfig({ servicePolicies: { postgrest: "off" } }); + expect(config.postgrest).toBe(false); + expect(config.servicePolicies.postgrest).toBe("off"); + }); + + it("rejects a preparation policy for a service that is not configured", async () => { + await expect(resolveConfig({ servicePolicies: { realtime: "eager" } })).rejects.toMatchObject({ + _tag: "StackBuildError", + reason: "invalid_config", + }); + }); + + it("rejects an eager service whose required public dependency is lazy before allocating ports", async () => { + let allocated = false; + await expect( + resolveConfig( + { + analytics: {}, + vector: {}, + servicePolicies: { analytics: "lazy", vector: "eager" }, + }, + { + portAllocator: () => { + allocated = true; + throw new Error("must not allocate"); + }, + }, + ), + ).rejects.toMatchObject({ + _tag: "StackBuildError", + reason: "invalid_config", + }); + expect(allocated).toBe(false); + }); +}); diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index 9aa29921a9..e819e5b6f3 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -1,6 +1,6 @@ import { mkdtempSync } from "node:fs"; import { join } from "node:path"; -import { Effect, Schema } from "effect"; +import { Effect, Record, Schema } from "effect"; import { StackBuildError, toStackError } from "./errors.ts"; import { resolvedFunctionsBundleSchemaForProject } from "./functions.ts"; import { @@ -17,7 +17,7 @@ import { type PortSelectionOptions, } from "./PortAllocator.ts"; import { PORT_CATALOG, type PortField, type PortSet, type ResolvedPorts } from "./PortCatalog.ts"; -import { portFieldsForConfigInput, serviceEnabledForConfig } from "./ServicePorts.ts"; +import { portFieldsForConfigInput } from "./ServicePorts.ts"; import { INSTANCE_ID_PATTERN, InstanceIdSchema, resolveReadinessPolicy } from "./StackConfig.ts"; import type { AnalyticsConfig, @@ -42,12 +42,22 @@ import type { ResolvedStorageConfig, ResolvedStudioConfig, ResolvedVectorConfig, + ServicePolicy, + ServicePolicyManifest, StackConfig, StorageConfig, StudioConfig, VectorConfig, } from "./StackConfig.ts"; -import { DEFAULT_VERSIONS } from "./ServiceCatalog.ts"; +import type { StackRuntimeSelection } from "./ContainerRuntime.ts"; +import { + DEFAULT_SERVICE_POLICIES, + DEFAULT_VERSIONS, + SERVICE_CATALOG, + SERVICE_NAMES, + serviceMetadata, +} from "./ServiceCatalog.ts"; +import type { ServiceName } from "./ServiceName.ts"; export interface ResolveConfigOptions { readonly stackRoot?: string; @@ -58,6 +68,7 @@ export interface ResolveConfigOptions { requests: ReadonlyArray, options: PortSelectionOptions, ) => Effect.Effect; + readonly runtime?: StackRuntimeSelection; } interface ResolvedRoots { @@ -344,29 +355,163 @@ const enabledServiceConfig = ( config: Config | false | undefined, ): Config | undefined => (enabled && config !== false ? config : undefined); +const rawServiceEnabled = (config: StackConfig, service: ServiceName): boolean => { + switch (service) { + case "postgres": + return true; + case "postgrest": + return config.postgrest !== false; + case "auth": + return config.auth !== false; + case "edge-runtime": + return ( + ((config.mode ?? "native") !== "native" || config.edgeRuntime !== undefined) && + config.edgeRuntime !== false && + (config.edgeRuntime?.enabled ?? true) !== false + ); + case "realtime": + return config.realtime !== undefined && config.realtime !== false; + case "storage": + return config.storage !== undefined && config.storage !== false; + case "imgproxy": + return config.imgproxy !== undefined && config.imgproxy !== false; + case "mailpit": + return config.mailpit !== undefined && config.mailpit !== false; + case "pgmeta": + return config.pgmeta !== undefined && config.pgmeta !== false; + case "studio": + return config.studio !== undefined && config.studio !== false; + case "analytics": + return config.analytics !== undefined && config.analytics !== false; + case "vector": + return config.vector !== undefined && config.vector !== false; + case "pooler": + return config.pooler !== undefined && config.pooler !== false; + } +}; + +const preparationPolicyRank: Readonly> = { + off: 0, + lazy: 1, + eager: 2, +}; + +/** + * Resolve policy declarations before roots, ports, or config-dependent effects + * are acquired. This keeps unsupported policies a pure user/configuration error. + */ +const resolveServicePolicies = (config: StackConfig): ServicePolicyManifest => { + const policies: Record = Record.map(SERVICE_CATALOG, () => "off"); + const requestedPolicies = config.servicePolicies ?? {}; + for (const service of SERVICE_NAMES) { + const requested = requestedPolicies[service]; + if (service === "postgres" && requested !== undefined && requested !== "eager") { + throw new StackBuildError({ + detail: "postgres supports only the eager service preparation policy", + reason: "invalid_config", + }); + } + + const enabled = rawServiceEnabled(config, service); + if (!enabled && requested !== undefined && requested !== "off") { + throw new StackBuildError({ + detail: `${service} cannot use the ${requested} service preparation policy because the service is not configured`, + reason: "invalid_config", + }); + } + if (!enabled || requested === "off") { + policies[service] = "off"; + continue; + } + + const policy: Exclude = + requested === undefined ? DEFAULT_SERVICE_POLICIES[service] : requested; + if (!serviceMetadata(service).preparation.supported.includes(policy)) { + throw new StackBuildError({ + detail: `${service} does not support the ${policy} service preparation policy`, + reason: "invalid_config", + }); + } + policies[service] = policy; + } + + let promoted = true; + while (promoted) { + promoted = false; + for (const service of SERVICE_NAMES) { + const policy = policies[service]; + if (policy === "off") continue; + for (const dependency of serviceMetadata(service).activation.activates) { + const dependencyPolicy = policies[dependency]; + if ( + dependencyPolicy === "off" || + preparationPolicyRank[dependencyPolicy] <= preparationPolicyRank[policy] + ) { + continue; + } + if (requestedPolicies[service] !== undefined) { + throw new StackBuildError({ + detail: `${dependency} uses the ${dependencyPolicy} preparation policy but requires ${service} to be at least ${dependencyPolicy}`, + reason: "invalid_config", + }); + } + policies[service] = dependencyPolicy; + promoted = true; + } + } + } + return policies; +}; + export async function resolveConfig( input?: StackConfig, opts: ResolveConfigOptions = {}, ): Promise { - const config = input ?? {}; + const inputConfig = input ?? {}; + if ( + inputConfig.mode !== undefined && + opts.runtime !== undefined && + inputConfig.mode !== opts.runtime.mode + ) { + throw new StackBuildError({ + detail: `Selected ${opts.runtime.mode} runtime does not match requested ${inputConfig.mode} mode`, + reason: "invalid_config", + }); + } + const resolvedMode = opts.runtime?.mode ?? inputConfig.mode ?? "native"; + if (resolvedMode === "docker" && opts.runtime?.containerRuntime == null) { + throw new StackBuildError({ + detail: "Docker mode requires a selected Docker or Podman runtime", + reason: "invalid_config", + }); + } + const containerRuntime = opts.runtime?.containerRuntime ?? null; + const config: StackConfig = { ...inputConfig, mode: resolvedMode }; + // Deliberately first: unsupported policies must not create roots or reserve ports. + const servicePolicies = resolveServicePolicies(config); const projectDir = config.projectDir ?? process.cwd(); const instanceId = resolveInstanceId(config.instanceId); const functions = await resolveFunctionsConfig(config, projectDir); - const resolvedMode = config.mode ?? "auto"; const roots = resolveRoots(config, opts); const postgresInput = config.postgres ?? {}; - const postgrestInput = config.postgrest !== false ? (config.postgrest ?? undefined) : undefined; - const authInput = config.auth !== false ? (config.auth ?? undefined) : undefined; - const edgeRuntimeEnabled = serviceEnabledForConfig(config, "edge-runtime"); - const realtimeEnabled = serviceEnabledForConfig(config, "realtime"); - const storageEnabled = serviceEnabledForConfig(config, "storage"); - const imgproxyEnabled = serviceEnabledForConfig(config, "imgproxy"); - const mailpitEnabled = serviceEnabledForConfig(config, "mailpit"); - const pgmetaEnabled = serviceEnabledForConfig(config, "pgmeta"); - const studioEnabled = serviceEnabledForConfig(config, "studio"); - const analyticsEnabled = serviceEnabledForConfig(config, "analytics"); - const vectorEnabled = serviceEnabledForConfig(config, "vector"); - const poolerEnabled = serviceEnabledForConfig(config, "pooler"); + const postgrestInput = + servicePolicies.postgrest !== "off" && config.postgrest !== false + ? (config.postgrest ?? undefined) + : undefined; + const authInput = + servicePolicies.auth !== "off" && config.auth !== false + ? (config.auth ?? undefined) + : undefined; + const edgeRuntimeEnabled = servicePolicies["edge-runtime"] !== "off"; + const realtimeEnabled = servicePolicies.realtime !== "off"; + const storageEnabled = servicePolicies.storage !== "off"; + const imgproxyEnabled = servicePolicies.imgproxy !== "off"; + const mailpitEnabled = servicePolicies.mailpit !== "off"; + const pgmetaEnabled = servicePolicies.pgmeta !== "off"; + const studioEnabled = servicePolicies.studio !== "off"; + const analyticsEnabled = servicePolicies.analytics !== "off"; + const vectorEnabled = servicePolicies.vector !== "off"; + const poolerEnabled = servicePolicies.pooler !== "off"; const edgeRuntimeInput = enabledServiceConfig(edgeRuntimeEnabled, config.edgeRuntime); const realtimeInput = enabledServiceConfig(realtimeEnabled, config.realtime); const storageInput = enabledServiceConfig(storageEnabled, config.storage); @@ -457,7 +602,8 @@ export async function resolveConfig( runtimeRoot: roots.runtimeRoot, projectDir, mode: resolvedMode, - startupMode: config.startupMode ?? "eager", + containerRuntime, + servicePolicies, readiness: resolveReadinessPolicy({ stackPolicy: config.readiness }), readinessSource: config.readiness === undefined ? "default" : "configured", jwtSecret, @@ -476,8 +622,17 @@ export async function resolveConfig( version: postgresInput.version ?? DEFAULT_VERSIONS.postgres, autoExposeNewTables: postgresInput.autoExposeNewTables ?? true, }, - postgrest: resolvePostgrestConfig(postgrestInput, config.postgrest, ports), - auth: resolveAuthConfig(authInput, config.auth, ports, apiPort), + postgrest: resolvePostgrestConfig( + postgrestInput, + servicePolicies.postgrest === "off" ? false : config.postgrest, + ports, + ), + auth: resolveAuthConfig( + authInput, + servicePolicies.auth === "off" ? false : config.auth, + ports, + apiPort, + ), edgeRuntime: edgeRuntimeEnabled ? resolveEdgeRuntimeConfig(edgeRuntimeInput, config.edgeRuntime, ports) : false, diff --git a/packages/stack/src/StackPreparation.ts b/packages/stack/src/StackPreparation.ts index 50761ed59e..19492433a1 100644 --- a/packages/stack/src/StackPreparation.ts +++ b/packages/stack/src/StackPreparation.ts @@ -1,13 +1,32 @@ -import { Cause, Data, Effect, Exit, Layer, Queue, Context, Stream } from "effect"; +import { + Cause, + Context, + Data, + Deferred, + Duration, + Effect, + Exit, + Layer, + Queue, + Schedule, + Stream, +} from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { BinaryResolver } from "./BinaryResolver.ts"; -import type { ChecksumMismatchError } from "./errors.ts"; -import { DockerPullError, isDockerDaemonDownMessage } from "./errors.ts"; -import { isDockerOnlyService } from "./ServiceCatalog.ts"; +import type { ContainerRuntime } from "./ContainerRuntime.ts"; +import type { + BinaryHostCompatibilityError, + BinaryManifestError, + BinaryRuntimeError, + ChecksumMismatchError, + DownloadError, +} from "./errors.ts"; +import { BinaryNotFoundError, DockerPullError, isDockerDaemonDownMessage } from "./errors.ts"; +import { isDockerOnlyService, requiredPreparationDependencies } from "./ServiceCatalog.ts"; import { DEFAULT_VERSIONS, SERVICE_NAMES, - dockerImageCandidatesForService, + dockerImageForService, type ServiceName, type VersionManifest, } from "./versions.ts"; @@ -16,16 +35,33 @@ export interface PreparedStackArtifacts { readonly resolutions: Partial>; } +export type PlannedStackArtifacts = PreparedStackArtifacts; + export type ServiceResolution = | { readonly type: "binary"; readonly path: string } | { readonly type: "docker"; readonly image: string }; -export interface StackPreparationInput { +interface StackPreparationOptions { readonly versions?: Partial; readonly services?: ReadonlyArray; - readonly mode?: "native" | "auto" | "docker"; + readonly enabledServices?: ReadonlyArray; } +export type StackPreparationInput = StackPreparationOptions & + ( + | { readonly mode: "native"; readonly containerRuntime?: never } + | { readonly mode: "docker"; readonly containerRuntime: ContainerRuntime } + ); + +export type StackPreparationError = + | BinaryNotFoundError + | DownloadError + | ChecksumMismatchError + | BinaryManifestError + | BinaryRuntimeError + | BinaryHostCompatibilityError + | DockerPullError; + export class ServiceDownloadStarted extends Data.TaggedClass("ServiceDownloadStarted")<{ readonly service: ServiceName; }> {} @@ -34,16 +70,15 @@ export class ServiceDownloadFinished extends Data.TaggedClass("ServiceDownloadFi readonly service: ServiceName; }> {} -class PreparationCompleted extends Data.TaggedClass("PreparationCompleted")<{ +export class PreparationCompleted extends Data.TaggedClass("PreparationCompleted")<{ readonly artifacts: PreparedStackArtifacts; }> {} -type StackPreparationEvent = +export type StackPreparationEvent = | ServiceDownloadStarted | ServiceDownloadFinished | PreparationCompleted; -const DOCKER_PULL_RETRY_DELAYS_MS = [500] as const; const RETRYABLE_PULL_PATTERNS = [ /toomanyrequests/i, /rate exceeded/i, @@ -56,104 +91,107 @@ const RETRYABLE_PULL_PATTERNS = [ /i\/o timeout/i, ] as const; -interface PullAttemptFailure { - readonly image: string; - readonly attempt: number; - readonly message: string; +class PullAttemptError extends Error { + constructor( + readonly detail: string, + readonly daemonDown: boolean, + ) { + super(detail); + this.name = "PullAttemptError"; + } } +const pullRetrySchedule = Schedule.exponential(Duration.seconds(1)).pipe( + Schedule.upTo({ times: 5 }), +); + const resolveDockerImageForService = ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + runtime: ContainerRuntime, service: ServiceName, version: string, callbacks?: { readonly onDownloadStart?: Effect.Effect; }, ): Effect.Effect => - pullImage(spawner, dockerImageCandidatesForService(service, version), callbacks); - -export const prepareAssetsWithDependencies = ( + pullImage(spawner, runtime, dockerImageForService(service, version), callbacks); + +export const preparationClosure = ( + services: ReadonlyArray, + enabledServices?: ReadonlyArray, +): ReadonlyArray => { + const enabled = enabledServices === undefined ? undefined : new Set(enabledServices); + const closure = new Set(); + const add = (service: ServiceName): void => { + if (enabled !== undefined && !enabled.has(service)) return; + if (closure.has(service)) return; + closure.add(service); + for (const dependency of requiredPreparationDependencies(service)) add(dependency); + }; + for (const service of services) add(service); + return [...closure]; +}; + +const selectedServices = (input: StackPreparationInput): ReadonlyArray => { + const defaults = + input.mode === "docker" + ? SERVICE_NAMES + : SERVICE_NAMES.filter((service) => !isDockerOnlyService(service)); + return preparationClosure(input.services ?? defaults, input.enabledServices); +}; + +const plannedResolution = ( resolver: BinaryResolver["Service"], - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - input?: StackPreparationInput, - publishEvent?: (event: StackPreparationEvent) => Effect.Effect, -): Effect.Effect => - Effect.gen(function* () { - const versions = { ...DEFAULT_VERSIONS, ...input?.versions }; - const services: ReadonlyArray = input?.services ?? SERVICE_NAMES; - const mode = input?.mode ?? "auto"; - - type Entry = readonly [ServiceName, ServiceResolution]; - - const resolveService = ( - service: ServiceName, - ): Effect.Effect => { - let isDownloading = false; - const markDownloadStart = () => - Effect.sync(() => { - isDownloading = true; - }).pipe( - Effect.andThen(publishEvent?.(new ServiceDownloadStarted({ service })) ?? Effect.void), - ); - const markDownloadFinished = () => - Effect.suspend(() => - isDownloading - ? (publishEvent?.(new ServiceDownloadFinished({ service })) ?? Effect.void) - : Effect.void, - ); - - if (mode === "docker") { - return resolveDockerImageForService(spawner, service, versions[service], { - onDownloadStart: markDownloadStart(), - }).pipe( - Effect.map((image): Entry => [service, { type: "docker", image }]), - Effect.ensuring(markDownloadFinished()), - ); - } - - if (isDockerOnlyService(service)) { - return resolveDockerImageForService(spawner, service, versions[service], { - onDownloadStart: markDownloadStart(), - }).pipe( - Effect.map((image): Entry => [service, { type: "docker", image }]), - Effect.ensuring(markDownloadFinished()), - ); - } - - return resolveServiceWithMetadata( - resolver, - spawner, - service, - versions[service], - markDownloadStart(), - ).pipe( - Effect.map((resolution): Entry => [service, resolution]), - Effect.ensuring(markDownloadFinished()), - ); - }; - - const results = yield* Effect.all(services.map(resolveService), { - concurrency: "unbounded", + service: ServiceName, + version: string, + mode: "native" | "docker", +): Effect.Effect => { + if (mode === "docker") { + return Effect.succeed({ + type: "docker", + image: dockerImageForService(service, version), }); + } + if (isDockerOnlyService(service)) { + return Effect.fail(new BinaryNotFoundError({ service, platform: "native" })); + } + return resolver + .plan({ service, version }) + .pipe(Effect.map((path): ServiceResolution => ({ type: "binary", path }))); +}; - const resolutions: Partial> = {}; - for (const [service, resolution] of results) { - resolutions[service] = resolution; - } - const artifacts = { resolutions } satisfies PreparedStackArtifacts; - yield* publishEvent?.(new PreparationCompleted({ artifacts })) ?? Effect.void; - return artifacts; +const planAssetsWithDependencies = ( + resolver: BinaryResolver["Service"], + input: StackPreparationInput, +): Effect.Effect => + Effect.gen(function* () { + const versions = { ...DEFAULT_VERSIONS, ...input.versions }; + const services = selectedServices(input); + const results = yield* Effect.all( + services.map((service) => + plannedResolution(resolver, service, versions[service], input.mode).pipe( + Effect.map((resolution) => [service, resolution] as const), + ), + ), + { concurrency: "unbounded" }, + ); + return { + resolutions: Object.fromEntries(results), + } satisfies PlannedStackArtifacts; }); export class StackPreparation extends Context.Service< StackPreparation, { + readonly plan: ( + input: StackPreparationInput, + ) => Effect.Effect; readonly prepare: ( - input?: StackPreparationInput, - ) => Effect.Effect; + input: StackPreparationInput, + ) => Effect.Effect; readonly prepareEvents: ( - input?: StackPreparationInput, - ) => Stream.Stream; + input: StackPreparationInput, + ) => Stream.Stream; } >()("stack/StackPreparation") { static layer: Layer.Layer< @@ -165,15 +203,110 @@ export class StackPreparation extends Context.Service< Effect.gen(function* () { const resolver = yield* BinaryResolver; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const scope = yield* Effect.scope; + const inFlight = new Map< + string, + Deferred.Deferred + >(); + + const materialize = ( + service: ServiceName, + resolution: ServiceResolution, + input: StackPreparationInput, + publishEvent?: (event: StackPreparationEvent) => Effect.Effect, + ): Effect.Effect => + Effect.uninterruptibleMask((restore) => + Effect.suspend(() => { + let downloadStarted = false; + const markDownloadStart = () => + Effect.sync(() => { + downloadStarted = true; + }).pipe( + Effect.andThen( + publishEvent?.(new ServiceDownloadStarted({ service })) ?? Effect.void, + ), + ); + const markDownloadFinished = () => + Effect.suspend(() => + downloadStarted + ? (publishEvent?.(new ServiceDownloadFinished({ service })) ?? Effect.void) + : Effect.void, + ); + const key = JSON.stringify({ + service, + resolution, + containerRuntime: input.mode === "docker" ? input.containerRuntime : null, + }); + const existing = inFlight.get(key); + if (existing !== undefined) return restore(Deferred.await(existing)); + const deferred = Deferred.makeUnsafe(); + inFlight.set(key, deferred); + const version = input.versions?.[service] ?? DEFAULT_VERSIONS[service]; + const effect: Effect.Effect = + resolution.type === "docker" + ? input.mode === "docker" + ? resolveDockerImageForService( + spawner, + input.containerRuntime, + service, + version, + { + onDownloadStart: markDownloadStart(), + }, + ).pipe(Effect.map((image): ServiceResolution => ({ type: "docker", image }))) + : Effect.die("Native preparation planned a Docker resolution") + : resolver + .resolveWithMetadata( + { service, version }, + { + onDownloadStart: markDownloadStart(), + }, + ) + .pipe(Effect.map(({ path }): ServiceResolution => ({ type: "binary", path }))); + const coordinated = effect.pipe( + Effect.matchCauseEffect({ + onSuccess: (value) => + Effect.andThen(markDownloadFinished(), Deferred.succeed(deferred, value)), + onFailure: (cause) => Deferred.failCause(deferred, cause), + }), + Effect.ensuring(Effect.sync(() => inFlight.delete(key))), + ); + return Effect.gen(function* () { + yield* Effect.forkIn(coordinated, scope, { startImmediately: true }); + return yield* restore(Deferred.await(deferred)); + }); + }), + ); + + const prepareWithEvents = ( + input: StackPreparationInput, + publishEvent?: (event: StackPreparationEvent) => Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const planned = yield* planAssetsWithDependencies(resolver, input); + const entries = yield* Effect.all( + selectedServices(input).map((service) => { + const resolution = planned.resolutions[service]; + if (resolution === undefined) return Effect.die(`Missing plan for ${service}`); + return materialize(service, resolution, input, publishEvent).pipe( + Effect.map((resolved) => [service, resolved] as const), + ); + }), + { concurrency: "unbounded" }, + ); + const artifacts = { + resolutions: Object.fromEntries(entries), + } satisfies PreparedStackArtifacts; + yield* publishEvent?.(new PreparationCompleted({ artifacts })) ?? Effect.void; + return artifacts; + }); return { - prepare: (input?: StackPreparationInput) => - prepareAssetsWithDependencies(resolver, spawner, input), - prepareEvents: (input?: StackPreparationInput) => - Stream.callback((queue) => - prepareAssetsWithDependencies(resolver, spawner, input, (event) => - Queue.offer(queue, event), - ).pipe( + plan: (input: StackPreparationInput) => planAssetsWithDependencies(resolver, input), + prepare: (input: StackPreparationInput) => prepareWithEvents(input), + prepareEvents: (input: StackPreparationInput) => + Stream.callback((queue) => + prepareWithEvents(input, (event) => Queue.offer(queue, event)).pipe( Effect.matchCauseEffect({ onFailure: (cause) => Queue.failCause(queue, cause), onSuccess: () => Queue.end(queue), @@ -188,155 +321,82 @@ export class StackPreparation extends Context.Service< const pullImage = ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - images: ReadonlyArray, + runtime: ContainerRuntime, + image: string, callbacks?: { readonly onDownloadStart?: Effect.Effect; }, ): Effect.Effect => Effect.gen(function* () { - const cachedImage = yield* findLocalDockerImage(spawner, images); - if (cachedImage !== undefined) { - return cachedImage; + if (yield* hasLocalDockerImage(spawner, runtime, image)) { + return image; } yield* callbacks?.onDownloadStart ?? Effect.void; - const failures: PullAttemptFailure[] = []; - let spawnFailed = false; - - for (const image of images) { - for ( - let attemptIndex = 0; - attemptIndex <= DOCKER_PULL_RETRY_DELAYS_MS.length; - attemptIndex += 1 - ) { - const attempt = attemptIndex + 1; - const result = yield* Effect.exit(runPullCommand(spawner, image)); - if (Exit.isSuccess(result)) { - // A successful spawn proves the runtime is usable; an earlier - // transient spawn failure must not taint the final classification. - spawnFailed = false; - if (result.value.exitCode === 0) { - return image; - } - - const message = - result.value.stderr.length > 0 - ? result.value.stderr - : `docker pull exited with code ${result.value.exitCode}`; - failures.push({ image, attempt, message }); - - if (!shouldRetryPull(message) || attemptIndex === DOCKER_PULL_RETRY_DELAYS_MS.length) { - break; - } - } else { - // A failed effect (rather than a non-zero exit) means the container - // runtime could not be spawned at all — a local Docker setup - // problem, not a registry failure. - spawnFailed = true; - const cause = Cause.squash(result.cause); - const message = cause instanceof Error ? cause.message : String(cause); - failures.push({ image, attempt, message }); - if (!shouldRetryPull(message) || attemptIndex === DOCKER_PULL_RETRY_DELAYS_MS.length) { - break; - } - } - - const retryDelay = DOCKER_PULL_RETRY_DELAYS_MS[attemptIndex]; - if (retryDelay === undefined) { - break; - } - yield* Effect.sleep(`${retryDelay} millis`); - } - } + const attempt = runPullCommand(spawner, runtime, image).pipe( + Effect.retry({ + while: (error) => shouldRetryPull(error.detail), + schedule: pullRetrySchedule, + }), + ); + const result = yield* Effect.exit(attempt); + if (Exit.isSuccess(result)) return image; - const detail = failures - .map((failure) => `${failure.image} attempt ${failure.attempt}: ${failure.message}`) - .join("; "); + const failure = Cause.squash(result.cause); + const detail = failure instanceof PullAttemptError ? failure.detail : String(failure); + const daemonDown = failure instanceof PullAttemptError && failure.daemonDown; return yield* Effect.fail( new DockerPullError({ - image: images[0] ?? "unknown", - detail: `Failed to pull Docker image from all registries. ${detail}`, + image, + detail: `Failed to pull canonical Docker image. ${detail}`, cause: new Error(detail), - daemonDown: - spawnFailed || failures.some((failure) => isDockerDaemonDownMessage(failure.message)), + daemonDown, }), ); }); -const resolveServiceWithMetadata = ( - resolver: BinaryResolver["Service"], - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - service: ServiceName, - version: string, - onDownloadStart: Effect.Effect, -): Effect.Effect => - resolver.resolveWithMetadata({ service, version }, { onDownloadStart }).pipe( - Effect.map(({ path }): ServiceResolution => ({ type: "binary", path })), - Effect.catchTag("BinaryNotFoundError", () => - resolveDockerImageForService(spawner, service, version, { - onDownloadStart, - }).pipe( - Effect.map((image): ServiceResolution => ({ - type: "docker", - image, - })), - ), - ), - Effect.catchTag("DownloadError", () => - resolveDockerImageForService(spawner, service, version, { - onDownloadStart, - }).pipe( - Effect.map((image): ServiceResolution => ({ - type: "docker", - image, - })), - ), - ), - ); - const runPullCommand = ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + runtime: ContainerRuntime, image: string, -): Effect.Effect<{ readonly exitCode: number; readonly stderr: string }, Error> => +): Effect.Effect<{ readonly exitCode: number; readonly stderr: string }, PullAttemptError> => Effect.gen(function* () { - const child = yield* spawner.spawn(ChildProcess.make("docker", ["pull", image])); + const child = yield* spawner.spawn(ChildProcess.make(runtime, ["pull", image])); const [stderr, exitCode] = yield* Effect.all( [collectStreamAsString(child.stderr), child.exitCode.pipe(Effect.map(Number))], { concurrency: "unbounded" }, ); - return { + const result = { exitCode, stderr: stderr.trim(), }; + if (result.exitCode !== 0) { + const detail = + result.stderr.length > 0 + ? result.stderr + : `${runtime} pull exited with code ${result.exitCode}`; + return yield* Effect.fail(new PullAttemptError(detail, isDockerDaemonDownMessage(detail))); + } + return result; }).pipe( Effect.scoped, - Effect.catchTag("PlatformError", (error) => Effect.fail(new Error(String(error)))), + Effect.catchTag("PlatformError", (error) => + Effect.fail(new PullAttemptError(String(error), true)), + ), ); const hasLocalDockerImage = ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + runtime: ContainerRuntime, image: string, ): Effect.Effect => - spawner.exitCode(ChildProcess.make("docker", ["image", "inspect", image])).pipe( + spawner.exitCode(ChildProcess.make(runtime, ["image", "inspect", image])).pipe( Effect.map((exitCode) => exitCode === 0), Effect.catchTag("PlatformError", () => Effect.succeed(false)), ); -const findLocalDockerImage = ( - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - images: ReadonlyArray, -): Effect.Effect => - Effect.gen(function* () { - for (const image of images) { - if (yield* hasLocalDockerImage(spawner, image)) { - return image; - } - } - return undefined; - }); - const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => Stream.runFold( stream, diff --git a/packages/stack/src/bun.ts b/packages/stack/src/bun.ts index 2d05640881..e4bd2caa93 100644 --- a/packages/stack/src/bun.ts +++ b/packages/stack/src/bun.ts @@ -2,9 +2,12 @@ import { BunServices } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { BinaryResolver } from "./BinaryResolver.ts"; +import { selectStackRuntime } from "./ContainerRuntime.ts"; import { createStack as createStackCore, type StackHandle } from "./createStack.ts"; +import { toStackError } from "./errors.ts"; import { prefetch as prefetchEffect, + type PrefetchEffectOptions, type PrefetchOptions, type PrefetchResult, } from "./prefetch.ts"; @@ -14,16 +17,30 @@ import { StackPreparation } from "./StackPreparation.ts"; import type { StackConfig } from "./StackConfig.ts"; export async function createStack(config?: StackConfig): Promise { - return createStackCore(config, platformFactory); + const runtime = await Effect.runPromise( + selectStackRuntime(config?.mode).pipe(Effect.provide(BunServices.layer)), + ).catch((error: unknown) => { + throw toStackError(error); + }); + return createStackCore(config, platformFactory, runtime); } export async function prefetch(options?: PrefetchOptions): Promise { + const runtime = await Effect.runPromise( + selectStackRuntime(options?.mode).pipe(Effect.provide(BunServices.layer)), + ).catch((error: unknown) => { + throw toStackError(error); + }); const resolverLayer = BinaryResolver.make(defaultCacheRoot()).pipe( Layer.provide(FetchHttpClient.layer), ); const preparationLayer = StackPreparation.layer.pipe(Layer.provide(resolverLayer)); + const resolvedOptions: PrefetchEffectOptions = + runtime.mode === "native" + ? { ...options, mode: "native" } + : { ...options, mode: "docker", containerRuntime: runtime.containerRuntime }; return Effect.runPromise( - prefetchEffect(options).pipe( + prefetchEffect(resolvedOptions).pipe( Effect.provide(preparationLayer), Effect.provide(BunServices.layer), ), diff --git a/packages/stack/src/cleanup.ts b/packages/stack/src/cleanup.ts index db5db4e596..7e9439fbb0 100644 --- a/packages/stack/src/cleanup.ts +++ b/packages/stack/src/cleanup.ts @@ -1,6 +1,7 @@ import { execFile } from "node:child_process"; import { existsSync, rmSync } from "node:fs"; import { Duration, Effect } from "effect"; +import type { ContainerRuntime } from "./ContainerRuntime.ts"; import type { CleanupTargets } from "./CleanupTargets.ts"; import { SERVICE_NAMES, serviceMetadata } from "./ServiceCatalog.ts"; import type { ResolvedStackConfig } from "./StackConfig.ts"; @@ -20,12 +21,15 @@ export const candidateCleanupTargets = (config: ResolvedStackConfig): CleanupTar * Force-remove Docker containers by name. Best-effort safety net — * silently ignores containers that don't exist or are already removed. */ -export const dockerForceRemove = (containerNames: ReadonlyArray): Effect.Effect => +export const dockerForceRemove = ( + runtime: ContainerRuntime, + containerNames: ReadonlyArray, +): Effect.Effect => Effect.forEach( containerNames, (name) => Effect.callback((resume) => { - const child = execFile("docker", ["rm", "-f", name], { timeout: 5_000 }, () => + const child = execFile(runtime, ["rm", "-f", name], { timeout: 5_000 }, () => resume(Effect.void), ); return Effect.sync(() => child.kill()); @@ -45,10 +49,6 @@ export function cleanupAutoManagedPaths(config: ResolvedStackConfig): void { // Best-effort — temp dir will be cleaned by OS eventually. } } - - try { - rmSync(`${config.postgres.dataDir}_pg_hba_docker.conf`, { force: true }); - } catch {} } const cleanupAutoManagedPathsWithRetry = (config: ResolvedStackConfig): Effect.Effect => @@ -57,10 +57,10 @@ const cleanupAutoManagedPathsWithRetry = (config: ResolvedStackConfig): Effect.E return; } - const cleanupTargets = [ - ...config.autoManagedPaths.map((path) => ({ path, recursive: true as const })), - { path: `${config.postgres.dataDir}_pg_hba_docker.conf`, recursive: false as const }, - ]; + const cleanupTargets = config.autoManagedPaths.map((path) => ({ + path, + recursive: true as const, + })); for (let attempt = 0; attempt < 80; attempt++) { yield* Effect.sync(() => { @@ -94,6 +94,11 @@ export const cleanupLocalStackResources = (opts: { // Safety net: force-remove any Docker containers that survived // signal-based shutdown. On macOS, killing the `docker run` client // may not stop the container. - yield* dockerForceRemove(opts.cleanupTargets.dockerContainerNames); + if (opts.config.containerRuntime !== null) { + yield* dockerForceRemove( + opts.config.containerRuntime, + opts.cleanupTargets.dockerContainerNames, + ); + } yield* cleanupAutoManagedPathsWithRetry(opts.config); }); diff --git a/packages/stack/src/createStack.integration.test.ts b/packages/stack/src/createStack.integration.test.ts index 59110eeadd..894408ac4c 100644 --- a/packages/stack/src/createStack.integration.test.ts +++ b/packages/stack/src/createStack.integration.test.ts @@ -1,76 +1,18 @@ import { afterEach, describe, expect, it } from "vitest"; -import { Effect } from "effect"; import { createStack, type StackHandle } from "./createStack.ts"; -import { reservePortSet } from "./PortAllocator.ts"; import { platformFactory } from "./platform-node.ts"; const handles: StackHandle[] = []; -const isAddressInUse = (error: unknown, depth = 0): boolean => { - if (depth > 4 || !(error instanceof Error)) return false; - if ("code" in error && Reflect.get(error, "code") === "EADDRINUSE") return true; - const cause: unknown = error.cause; - if (typeof cause === "object" && cause !== null && "code" in cause) { - if (Reflect.get(cause, "code") === "EADDRINUSE") return true; - } - return isAddressInUse(cause, depth + 1); -}; - -const freshPortPair = async (): Promise => - Effect.runPromise( - Effect.scoped( - Effect.acquireRelease( - reservePortSet([ - { field: "apiPort", selection: { kind: "automatic" } }, - { field: "dbPort", selection: { kind: "automatic" } }, - ]), - (lease) => lease.releaseAll, - ).pipe( - Effect.map((lease) => { - const apiPort = lease.ports.apiPort; - const dbPort = lease.ports.dbPort; - if (apiPort === undefined || dbPort === undefined) { - throw new Error("Ephemeral port reservation returned an incomplete pair"); - } - return [apiPort, dbPort] as const; - }), - ), - ), - ); - -/** Transfer a fresh exact pair into public createStack across a bounded bind handoff retry. */ -const createStackWithFreshPorts = async ( - config: Parameters[0], - platform: Parameters[1], -): Promise>> => { - for (let attempt = 0; attempt < 3; attempt += 1) { - const [apiPort, dbPort] = await freshPortPair(); - try { - return await createStack( - { - ...config, - port: apiPort, - postgres: { ...config?.postgres, port: dbPort }, - }, - platform, - ); - } catch (error) { - if (!isAddressInUse(error) || attempt === 2) throw error; - } - } - throw new Error("Direct stack bind handoff exhausted retries"); -}; - afterEach(async () => { await Promise.all(handles.splice(0).map((handle) => handle.dispose())); }); describe("direct createStack port ownership", () => { it("allocates only active service fields without managed state", async () => { - const stack = await createStackWithFreshPorts( + const stack = await createStack( { mode: "native", - startupMode: "lazy", postgrest: false, auth: false, edgeRuntime: false, @@ -85,6 +27,7 @@ describe("direct createStack port ownership", () => { pooler: false, }, platformFactory, + { mode: "native", containerRuntime: null }, ); handles.push(stack); diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index a80dfc0b04..2e6f31e2bd 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -3,6 +3,7 @@ import { Context, Effect, FileSystem, type Layer, ManagedRuntime, Path, Stream } import { HttpServer } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ApiProxy } from "./ApiProxy.ts"; +import type { StackRuntimeSelection } from "./ContainerRuntime.ts"; import { candidateCleanupTargets, cleanupAutoManagedPaths, dockerForceRemove } from "./cleanup.ts"; import { toStackError } from "./errors.ts"; import type { FunctionsReloadConfig } from "./functions.ts"; @@ -70,14 +71,33 @@ export interface StackHandle extends AsyncDisposable { logHistory(name: string, limit?: number): Promise>; } -export async function createStack( +const MAX_AUTOMATIC_API_PORT_HANDOFF_ATTEMPTS = 3; + +/** + * The port lease is intentionally released just before the HTTP server binds. + * Another process can claim that port in the small handoff window, so a new + * foreground stack may retry its automatic API-port allocation. Explicit API + * ports never enter this retry path. + */ +const isAddressInUse = (error: unknown, depth = 0): boolean => { + if (depth > 8 || typeof error !== "object" || error === null) return false; + if ("code" in error && Reflect.get(error, "code") === "EADDRINUSE") return true; + if ("cause" in error) return isAddressInUse(Reflect.get(error, "cause"), depth + 1); + return false; +}; + +const createStackAttempt = async ( config: StackConfig | undefined, platformFactory: PlatformFactory, -): Promise { + runtime: StackRuntimeSelection, + preferredApiPort?: number, +): Promise => { let portLease: PortLease | undefined; let resolved: ResolvedStackConfig; try { resolved = await resolveConfig(config, { + runtime, + ...(preferredApiPort === undefined ? {} : { preferredPorts: { apiPort: preferredApiPort } }), portAllocator: (requests, options) => reservePortSet(requests, options).pipe( Effect.tap((lease) => @@ -162,9 +182,42 @@ export async function createStack( } catch (error: unknown) { await Effect.runPromise(portLease.releaseAll); await Effect.runPromise( - dockerForceRemove(candidateCleanupTargets(resolved).dockerContainerNames), + resolved.containerRuntime === null + ? Effect.void + : dockerForceRemove( + resolved.containerRuntime, + candidateCleanupTargets(resolved).dockerContainerNames, + ), ); cleanupAutoManagedPaths(resolved); throw toStackError(error); } +}; + +export async function createStack( + config: StackConfig | undefined, + platformFactory: PlatformFactory, + runtime: StackRuntimeSelection, +): Promise { + const automaticApiPort = config?.port === undefined; + for (let attempt = 0; ; attempt += 1) { + try { + // Port zero asks the allocator for an OS-assigned automatic port, + // avoiding another attempt at a contended default during handoff. + return await createStackAttempt( + config, + platformFactory, + runtime, + attempt === 0 ? undefined : 0, + ); + } catch (error) { + if ( + !automaticApiPort || + !isAddressInUse(error) || + attempt + 1 >= MAX_AUTOMATIC_API_PORT_HANDOFF_ATTEMPTS + ) { + throw error; + } + } + } } diff --git a/packages/stack/src/createStack.unit.test.ts b/packages/stack/src/createStack.unit.test.ts index d0962a7191..459aec256d 100644 --- a/packages/stack/src/createStack.unit.test.ts +++ b/packages/stack/src/createStack.unit.test.ts @@ -83,10 +83,35 @@ describe("resolveConfig edge runtime defaults", () => { expect(config.edgeRuntime).toBe(false); }); - it("enables edge runtime when omitted in auto mode", async () => { - const config = await resolveConfig(); + it("enables edge runtime when omitted in Docker mode", async () => { + const config = await resolveConfig( + { mode: "docker" }, + { runtime: { mode: "docker", containerRuntime: "docker" } }, + ); + + expect(config.mode).toBe("docker"); + expect(config.edgeRuntime).toEqual( + expect.objectContaining({ + enabled: true, + version: DEFAULT_VERSIONS["edge-runtime"], + }), + ); + }); + + it("requires Effect consumers to provide the selected Docker runtime", async () => { + await expect(resolveConfig({ mode: "docker" })).rejects.toMatchObject({ + _tag: "StackBuildError", + reason: "invalid_config", + }); + }); + + it("applies the detected Docker mode before resolving services and ports", async () => { + const config = await resolveConfig(undefined, { + runtime: { mode: "docker", containerRuntime: "podman" }, + }); - expect(config.mode).toBe("auto"); + expect(config.mode).toBe("docker"); + expect(config.containerRuntime).toBe("podman"); expect(config.edgeRuntime).toEqual( expect.objectContaining({ enabled: true, @@ -110,13 +135,16 @@ describe("resolveConfig edge runtime defaults", () => { describe("resolveConfig explicit keyless ports", () => { it("preserves an explicit pooler api port", async () => { - const config = await resolveConfig({ - mode: "docker", - edgeRuntime: false, - postgrest: false, - auth: false, - pooler: { port: 42423, apiPort: 42424 }, - }); + const config = await resolveConfig( + { + mode: "docker", + edgeRuntime: false, + postgrest: false, + auth: false, + pooler: { port: 42423, apiPort: 42424 }, + }, + { runtime: { mode: "docker", containerRuntime: "docker" } }, + ); expect(config.ports.poolerPort).toBe(42423); expect(config.ports.poolerApiPort).toBe(42424); @@ -155,20 +183,23 @@ describe("resolveConfig explicit keyless ports", () => { describe("candidateCleanupTargets", () => { it("derives fallback Docker identities from enabled catalog services", async () => { - const config = await resolveConfig({ - mode: "docker", - auth: false, - edgeRuntime: false, - realtime: false, - storage: false, - imgproxy: false, - mailpit: false, - pgmeta: false, - studio: false, - analytics: false, - vector: false, - pooler: false, - }); + const config = await resolveConfig( + { + mode: "docker", + auth: false, + edgeRuntime: false, + realtime: false, + storage: false, + imgproxy: false, + mailpit: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, + }, + { runtime: { mode: "docker", containerRuntime: "docker" } }, + ); expect(candidateCleanupTargets(config)).toEqual({ dockerContainerNames: [ @@ -180,7 +211,10 @@ describe("candidateCleanupTargets", () => { it("keys fallback Docker identities by the stack's own identity when it has one", async () => { const instanceId = "0f9d2b3c-4a5e-4c7d-8e9f-1a2b3c4d5e6f"; - const config = await resolveConfig({ mode: "docker", instanceId }); + const config = await resolveConfig( + { mode: "docker", instanceId }, + { runtime: { mode: "docker", containerRuntime: "docker" } }, + ); expect(config.instanceId).toBe(instanceId); const { dockerContainerNames } = candidateCleanupTargets(config); @@ -211,21 +245,18 @@ describe("resolveConfig instanceId validation", () => { }); }); -describe("resolveConfig startup mode", () => { - it("keeps eager startup as the package default", async () => { - const config = await resolveConfig(); - expect(config.startupMode).toBe("eager"); - }); - - it("preserves an explicit lazy startup mode", async () => { - const config = await resolveConfig({ startupMode: "lazy" }); - expect(config.startupMode).toBe("lazy"); +describe("resolveConfig service policies", () => { + it("uses catalog defaults and resolves explicit policies", async () => { + const config = await resolveConfig({ servicePolicies: { postgrest: "eager" } }); + expect(config.servicePolicies.postgres).toBe("eager"); + expect(config.servicePolicies.postgrest).toBe("eager"); + expect(config.servicePolicies.auth).toBe("lazy"); }); }); describe("resolveConfig state roots", () => { it("uses disposable temporary roots when direct callers omit them", async () => { - const config = await resolveConfig({ startupMode: "lazy" }); + const config = await resolveConfig(); try { expect(config.autoManagedPaths).toEqual([config.stackRoot, config.runtimeRoot]); diff --git a/packages/stack/src/effect-bun.ts b/packages/stack/src/effect-bun.ts index 490b6cedd8..409b895276 100644 --- a/packages/stack/src/effect-bun.ts +++ b/packages/stack/src/effect-bun.ts @@ -66,7 +66,7 @@ export const updateManagedLaunch = (opts: { readonly stackName?: string; readonly cwd?: string; readonly cacheRoot: string; - readonly launch: NonNullable; + readonly launch: import("./managed/document.ts").ManagedStackLaunchUpdate; }) => updateManagedLaunchCore(opts).pipe( Effect.provide(managedLayer(opts.cacheRoot)), diff --git a/packages/stack/src/effect-node.ts b/packages/stack/src/effect-node.ts index 97f5841af2..1959dbce15 100644 --- a/packages/stack/src/effect-node.ts +++ b/packages/stack/src/effect-node.ts @@ -66,7 +66,7 @@ export const updateManagedLaunch = (opts: { readonly stackName?: string; readonly cwd?: string; readonly cacheRoot: string; - readonly launch: NonNullable; + readonly launch: import("./managed/document.ts").ManagedStackLaunchUpdate; }) => updateManagedLaunchCore(opts).pipe( Effect.provide(managedLayer(opts.cacheRoot)), diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index 56ca2752cf..0c4c92113a 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -5,7 +5,10 @@ export type { StackServiceStatus } from "./StackServiceState.ts"; export { StackServiceState, fromRawServiceState } from "./StackServiceState.ts"; export { + BinaryHostCompatibilityError, + BinaryManifestError, BinaryNotFoundError, + BinaryRuntimeError, ChecksumMismatchError, DockerPullError, DownloadError, @@ -17,17 +20,15 @@ export { toStackError, } from "./errors.ts"; -export type { PlatformInfo } from "./Platform.ts"; -export { - authAssetName, - detectPlatform, - postgresAssetName, - postgrestAssetName, -} from "./Platform.ts"; +export type { NativeTarget, PlatformInfo } from "./Platform.ts"; +export { detectPlatform, nativeTargetForPlatform } from "./Platform.ts"; + +export type { ContainerRuntime, StackRuntimeSelection } from "./ContainerRuntime.ts"; +export { selectStackRuntime, validateStackRuntime } from "./ContainerRuntime.ts"; -export type { ServiceResolution } from "./StackPreparation.ts"; +export type { ServiceResolution, StackPreparationError } from "./StackPreparation.ts"; -export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; +export type { PrefetchEffectOptions, PrefetchOptions, PrefetchResult } from "./prefetch.ts"; export { prefetch } from "./prefetch.ts"; export { @@ -93,6 +94,9 @@ export type { ResolvedVectorConfig, ReadinessPolicy, ReadyOptions, + ServicePolicy, + ServicePolicyManifest, + StackMode, StackConfig, StorageConfig, StudioConfig, @@ -126,7 +130,6 @@ export { dockerImageForService, fillServiceVersionManifest, fullVersionManifest, - IMAGE_TAG_PREFIX, normalizeServiceVersion, normalizeServiceVersions, SERVICE_NAMES, diff --git a/packages/stack/src/errors.ts b/packages/stack/src/errors.ts index 4d30ce2c47..168a9fe47f 100644 --- a/packages/stack/src/errors.ts +++ b/packages/stack/src/errors.ts @@ -16,6 +16,21 @@ export class ChecksumMismatchError extends Data.TaggedError("ChecksumMismatchErr readonly actual: string; }> {} +export class BinaryManifestError extends Data.TaggedError("BinaryManifestError")<{ + readonly url: string; + readonly detail: string; +}> {} + +export class BinaryRuntimeError extends Data.TaggedError("BinaryRuntimeError")<{ + readonly path: string; + readonly detail: string; +}> {} + +export class BinaryHostCompatibilityError extends Data.TaggedError("BinaryHostCompatibilityError")<{ + readonly target: string; + readonly detail: string; +}> {} + export class DockerPullError extends Data.TaggedError("DockerPullError")<{ readonly image: string; readonly detail: string; @@ -126,6 +141,12 @@ export function toStackError(err: unknown): StackError { message: taggedMessage, cause: err, }); + case "BinaryManifestError": + return new StackError({ code: "BINARY_MANIFEST", message: taggedMessage, cause: err }); + case "BinaryRuntimeError": + return new StackError({ code: "BINARY_RUNTIME", message: taggedMessage, cause: err }); + case "BinaryHostCompatibilityError": + return new StackError({ code: "BINARY_HOST", message: taggedMessage, cause: err }); case "DownloadError": return new StackError({ code: "DOWNLOAD_ERROR", diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index 46f9298c83..7aed30c261 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -84,7 +84,14 @@ const authFailureCases = [ describe("stack Functions runtime config", () => { it("projects an explicit bundle without project discovery", async () => { const root = makeTempProject(); - const stackConfig = await resolveConfig({ projectDir: root, functions: makeBundle(root) }); + const stackConfig = await resolveConfig( + { + mode: "docker", + projectDir: root, + functions: makeBundle(root), + }, + { runtime: { mode: "docker", containerRuntime: "docker" } }, + ); const config = resolveFunctionsRuntimeConfig( stackConfig, { hostname: "127.0.0.1" }, @@ -212,7 +219,10 @@ describe("stack Functions runtime config", () => { return Effect.gen(function* () { const bundle = makeBundle(cwd); const stackConfig = yield* Effect.promise(() => - resolveConfig({ projectDir: cwd, runtimeRoot: cwd, functions: bundle }), + resolveConfig( + { mode: "docker", projectDir: cwd, runtimeRoot: cwd, functions: bundle }, + { runtime: { mode: "docker", containerRuntime: "docker" } }, + ), ); yield* configureFunctionsRuntime(stackConfig, { hostname: "127.0.0.1" }, bundle); const filePath = functionsRuntimeConfigPath(stackConfig.runtimeRoot); diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 3969233f6f..43e077632a 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -16,6 +16,9 @@ export type { RealtimeConfig, ReadinessPolicy, ReadyOptions, + ServicePolicy, + ServicePolicyManifest, + StackMode, StackConfig, StorageConfig, StudioConfig, @@ -23,7 +26,7 @@ export type { } from "./StackConfig.ts"; export type { ServiceName, VersionManifest } from "./versions.ts"; -export type { ServiceResolution } from "./StackPreparation.ts"; +export type { ServiceResolution, StackPreparationError } from "./StackPreparation.ts"; export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; export type { StackHandle } from "./createStack.ts"; export type { diff --git a/packages/stack/src/layers.ts b/packages/stack/src/layers.ts index f6b8170b57..46204768de 100644 --- a/packages/stack/src/layers.ts +++ b/packages/stack/src/layers.ts @@ -15,7 +15,7 @@ import type { ResolvedStackConfig } from "./StackConfig.ts"; import { sanitizeDaemonConfigInput, type DaemonConfigInput } from "./StackConfigResolver.ts"; import { HttpTransportClient } from "./HttpTransportClient.ts"; import { DEFAULT_MANAGED_STACK_NAME, defaultCacheRoot } from "./paths.ts"; -import type { ManagedStackDocument } from "./managed/document.ts"; +import type { ManagedStackLaunchInput } from "./managed/document.ts"; import type { ManagedPortIntentDocument } from "./managed/model.ts"; import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; @@ -107,7 +107,7 @@ export class DaemonStartError extends Data.TaggedError("DaemonStartError")<{ /** Managed-only additions kept outside the generic daemon config resolver. */ export type ManagedDaemonConfigInput = DaemonConfigInput & { readonly portIntents: ManagedPortIntentDocument; - readonly launch?: ManagedStackDocument["launch"]; + readonly launch?: ManagedStackLaunchInput; }; // --------------------------------------------------------------------------- diff --git a/packages/stack/src/managed-manager-lifecycle.integration.test.ts b/packages/stack/src/managed-manager-lifecycle.integration.test.ts index dcba0d8407..5004e226f7 100644 --- a/packages/stack/src/managed-manager-lifecycle.integration.test.ts +++ b/packages/stack/src/managed-manager-lifecycle.integration.test.ts @@ -46,7 +46,7 @@ describe("managed stack lifecycle journeys", () => { const manager = yield* ManagedStackManager; const [apiPort, dbPort] = yield* freePorts(2); if (apiPort === undefined || dbPort === undefined) { - throw new Error("expected interrupted-delete ports"); + throw new Error("expected free managed stack ports"); } const portDocument = exactCoreDocument(apiPort, dbPort); const environment = yield* ensureEnvironment(workspace); @@ -59,6 +59,7 @@ describe("managed stack lifecycle journeys", () => { portDocument, ownership: owner, lifecycle: "running", + launch: { mode: "native", versions: {} }, }); yield* releaseLease(started); yield* owner.close; @@ -67,13 +68,12 @@ describe("managed stack lifecycle journeys", () => { workspacePath: workspace, stackName: "default", launch: { - mode: "auto" as const, versions: { postgres: "17.6.1" }, excludedServices: [], }, }; const updated = yield* updateManagedLaunch(input); - expect(updated.launch).toEqual(input.launch); + expect(updated.launch).toEqual({ mode: "native", ...input.launch }); yield* stopManagedStack(input); const stopped = yield* manager.inspectStack(stackId); @@ -89,6 +89,55 @@ describe("managed stack lifecycle journeys", () => { ); }); + it.live("updates launch metadata before a runtime has been selected", () => { + const { layer, workspace } = setup(); + return Effect.scoped( + Effect.gen(function* () { + const manager = yield* ManagedStackManager; + const [apiPort, dbPort] = yield* freePorts(2); + if (apiPort === undefined || dbPort === undefined) { + throw new Error("expected free managed stack ports"); + } + const environment = yield* ensureEnvironment(workspace); + const stackId = deriveStackId(environment.identity, "default"); + const owner = yield* acquireControl({ stackId }); + if (owner._tag !== "Owned") throw new Error("expected stack control ownership"); + const started = yield* manager.startStack({ + workspacePath: workspace, + stackName: "default", + portDocument: exactCoreDocument(apiPort, dbPort), + ownership: owner, + lifecycle: "running", + }); + yield* releaseLease(started); + yield* owner.close; + + const updated = yield* updateManagedLaunch({ + workspacePath: workspace, + stackName: "default", + launch: { + versions: { postgres: "17.6.1" }, + excludedServices: ["studio"], + lastNotifiedUpdateFingerprint: "fingerprint", + }, + }); + + expect(updated.launch).toEqual({ + versions: { postgres: "17.6.1" }, + excludedServices: ["studio"], + lastNotifiedUpdateFingerprint: "fingerprint", + }); + }), + ).pipe( + Effect.provide(layer), + Effect.provide(NodeFileSystem.layer), + Effect.provide(NodePath.layer), + Effect.provide(gitConfigStoreLayer), + Effect.provide(controlTransportLayer), + Effect.provide(httpTransportClientLayer), + ); + }); + it.live("stops an owner whose document is still starting", () => { const { layer, workspace } = setup(); return Effect.scoped( @@ -203,9 +252,12 @@ describe("managed stack lifecycle journeys", () => { portDocument: automaticDocument(), ownership: owner, lifecycle: "running", + launch: { mode: "native", versions: {} }, }); yield* releaseLease(initial); - const launch = { mode: "auto" as const, versions: { postgres: "17.6.1" } }; + const launch = { + versions: { postgres: "17.6.1" }, + }; gate.enabled = true; const launchFiber = yield* Effect.forkScoped( manager.updateLaunch(owner, { stackId, launch }), @@ -231,7 +283,7 @@ describe("managed stack lifecycle journeys", () => { yield* Fiber.join(stopFiber); const final = yield* manager.inspectStack(stackId); expect(final?.lifecycle).toBe("stopped"); - expect(final?.launch).toEqual(launch); + expect(final?.launch).toEqual({ mode: "native", ...launch }); }), ).pipe( Effect.provide(managerLayer), diff --git a/packages/stack/src/managed-store.integration.test.ts b/packages/stack/src/managed-store.integration.test.ts index 3154555e76..a1baf516b1 100644 --- a/packages/stack/src/managed-store.integration.test.ts +++ b/packages/stack/src/managed-store.integration.test.ts @@ -139,6 +139,7 @@ describe("managed stack document store", () => { document({ launch: { mode: "docker", + containerRuntime: "docker", versions: { postgres: "17.6.1" }, excludedServices: ["studio", "analytics"], lastNotifiedUpdateFingerprint: "fingerprint", @@ -147,6 +148,7 @@ describe("managed stack document store", () => { ); expect((yield* store.read(STACK_ID))?.launch).toEqual({ mode: "docker", + containerRuntime: "docker", versions: { postgres: "17.6.1" }, excludedServices: ["studio", "analytics"], lastNotifiedUpdateFingerprint: "fingerprint", diff --git a/packages/stack/src/managed.ts b/packages/stack/src/managed.ts index 51b1a65411..79cfbe8538 100644 --- a/packages/stack/src/managed.ts +++ b/packages/stack/src/managed.ts @@ -67,8 +67,9 @@ export type { AllocateManagedPortsRequest, ReadStackRequest, StartStackRequest, - ManagedStackLaunchUpdate, + ManagedStackLaunchUpdateRequest, } from "./managed/manager.ts"; +export type { ManagedStackLaunchUpdate } from "./managed/document.ts"; export { connectManagedStack, deleteManagedStack, diff --git a/packages/stack/src/managed/document.ts b/packages/stack/src/managed/document.ts index 843d967e16..2299e5d870 100644 --- a/packages/stack/src/managed/document.ts +++ b/packages/stack/src/managed/document.ts @@ -1,6 +1,6 @@ import { Data, Effect, Schema } from "effect"; import type { ManagedPortAssignment } from "./model.ts"; -import { PartialVersionManifestSchema, type PartialVersionManifest } from "../versions.ts"; +import { PartialVersionManifestSchema } from "../versions.ts"; export type ManagedStackDocumentLifecycle = | "stopped" @@ -9,6 +9,30 @@ export type ManagedStackDocumentLifecycle = | "deleting" | "failed"; +const managedStackLaunchFields = { + versions: PartialVersionManifestSchema, + excludedServices: Schema.optionalKey(Schema.Array(Schema.String)), + lastNotifiedUpdateFingerprint: Schema.optionalKey(Schema.String), +} as const; + +export const managedStackLaunchUpdateSchema = Schema.Struct(managedStackLaunchFields); +export type ManagedStackLaunchUpdate = Schema.Schema.Type; + +const managedStackLaunchSchema = Schema.Union([ + Schema.Struct({ + mode: Schema.Literal("native"), + ...managedStackLaunchFields, + }), + Schema.Struct({ + mode: Schema.Literal("docker"), + containerRuntime: Schema.Literals(["docker", "podman"] as const), + ...managedStackLaunchFields, + }), + Schema.Struct(managedStackLaunchFields), +]); + +export type ManagedStackLaunch = Schema.Schema.Type; + export interface ManagedStackDocument { readonly format: "supabase-stack"; readonly formatVersion: 1; @@ -33,24 +57,18 @@ export interface ManagedStackDocument { readonly controlEndpoint: string; readonly protocolVersion: 1; }; - readonly launch?: { - readonly mode: "native" | "auto" | "docker"; - readonly versions: PartialVersionManifest; - readonly excludedServices?: ReadonlyArray; - readonly lastNotifiedUpdateFingerprint?: string; - }; + readonly launch?: ManagedStackLaunch; readonly createdAt: string; readonly updatedAt: string; } -export const managedStackLaunchSchema = Schema.Struct({ - mode: Schema.Literals(["native", "auto", "docker"] as const), - versions: PartialVersionManifestSchema, - excludedServices: Schema.optionalKey(Schema.Array(Schema.String)), - lastNotifiedUpdateFingerprint: Schema.optionalKey(Schema.String), +/** Launch request before the supervisor selects a concrete execution mode. */ +export const managedStackLaunchInputSchema = Schema.Struct({ + mode: Schema.optionalKey(Schema.Literals(["native", "docker"] as const)), + ...managedStackLaunchFields, }); -export type ManagedStackLaunch = Schema.Schema.Type; +export type ManagedStackLaunchInput = Schema.Schema.Type; const managedPortAssignmentSchema = Schema.Struct({ key: Schema.Literals([ diff --git a/packages/stack/src/managed/lifecycle.ts b/packages/stack/src/managed/lifecycle.ts index fc77333617..95e5850e54 100644 --- a/packages/stack/src/managed/lifecycle.ts +++ b/packages/stack/src/managed/lifecycle.ts @@ -6,14 +6,14 @@ import { dockerForceRemove } from "../cleanup.ts"; import { dockerContainerName } from "../StackIdentity.ts"; import { SERVICE_NAMES } from "../ServiceCatalog.ts"; import { HttpTransportClient, HttpTransportClientError } from "../HttpTransportClient.ts"; -import type { ManagedStackDocument } from "./document.ts"; +import type { ManagedStackDocument, ManagedStackLaunchUpdate } from "./document.ts"; import { ManagedStackAttachedError, ManagedStackManager, ManagedWorkspaceRepairConflictError, workspaceRepairConflict, type ManagedStackManagerError, - type ManagedStackLaunchUpdate, + type ManagedStackLaunchUpdateRequest, } from "./manager.ts"; import { ControlTransportError } from "./control.ts"; import { @@ -114,6 +114,12 @@ export const stopManagedStack = ( const manager = yield* ManagedStackManager; const document = yield* resolveManagedDocument(input); const stackId = document.id; + const containerRuntime = + document.launch !== undefined && + "mode" in document.launch && + document.launch.mode === "docker" + ? document.launch.containerRuntime + : null; const acquisition = yield* manager.acquireControl(stackId); const revalidatedStackId = yield* stackIdForInput(manager, input); if (revalidatedStackId !== stackId) { @@ -129,9 +135,12 @@ export const stopManagedStack = ( document.lifecycle === "starting" || document.lifecycle === "failed" ) { - yield* dockerForceRemove( - SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), - ); + if (containerRuntime !== null) { + yield* dockerForceRemove( + containerRuntime, + SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), + ); + } yield* manager.recordLifecycle(acquisition, { stackId, lifecycle: "stopped" }); } yield* acquisition.close; @@ -144,9 +153,12 @@ export const stopManagedStack = ( const cleanupOwned = (owned: import("./control.ts").ControlOwnership) => Effect.ensuring( Effect.gen(function* () { - yield* dockerForceRemove( - SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), - ); + if (containerRuntime !== null) { + yield* dockerForceRemove( + containerRuntime, + SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), + ); + } yield* manager.recordLifecycle(owned, { stackId, lifecycle: "stopped" }); }), owned.close, @@ -285,7 +297,7 @@ export const deleteManagedStack = ( /** Persist launch selections in the managed document, owner-gated. */ export const updateManagedLaunch = ( - input: ManagedLifecycleInput & { readonly launch: NonNullable }, + input: ManagedLifecycleInput & { readonly launch: ManagedStackLaunchUpdate }, ): Effect.Effect< ManagedStackDocument, NoRunningStackError | ManagedStackManagerError | HttpTransportClientError, @@ -313,7 +325,10 @@ export const updateManagedLaunch = ( if (next === undefined) return yield* Effect.fail(noRunningStack(input)); return next; } - const update: ManagedStackLaunchUpdate = { stackId: document.id, launch: input.launch }; + const update: ManagedStackLaunchUpdateRequest = { + stackId: document.id, + launch: input.launch, + }; return yield* Effect.ensuring(manager.updateLaunch(acquisition, update), acquisition.close); }), ); diff --git a/packages/stack/src/managed/manager.ts b/packages/stack/src/managed/manager.ts index 7746b11c8a..4faf99e142 100644 --- a/packages/stack/src/managed/manager.ts +++ b/packages/stack/src/managed/manager.ts @@ -65,7 +65,7 @@ import { } from "./port-plan.ts"; import { resolvePortIntents } from "./port-intent.ts"; import { makeStackStore, type ManagedStackListing } from "./store.ts"; -import type { ManagedStackDocument } from "./document.ts"; +import type { ManagedStackDocument, ManagedStackLaunchUpdate } from "./document.ts"; import { dockerForceRemove } from "../cleanup.ts"; import { SERVICE_NAMES } from "../ServiceCatalog.ts"; import { dockerContainerName } from "../StackIdentity.ts"; @@ -119,9 +119,9 @@ export interface ManagedStackLifecycleUpdate { readonly runtime?: ManagedStackDocument["runtime"] | null; } -export interface ManagedStackLaunchUpdate { +export interface ManagedStackLaunchUpdateRequest { readonly stackId: string; - readonly launch: NonNullable; + readonly launch: ManagedStackLaunchUpdate; } export interface ManagedPortLease { @@ -240,7 +240,7 @@ export interface ManagedStackManagerShape { /** Persist launch selections under the stack's control ownership. */ readonly updateLaunch: ( ownership: ControlOwnership, - update: ManagedStackLaunchUpdate, + update: ManagedStackLaunchUpdateRequest, ) => Effect.Effect; readonly repairWorkspace: ( request: RepairRequest, @@ -843,7 +843,7 @@ const makeManager = ( const updateLaunch = ( ownership: ControlOwnership, - update: ManagedStackLaunchUpdate, + update: ManagedStackLaunchUpdateRequest, ): Effect.Effect => lifecycleLock.withPermit( Effect.gen(function* () { @@ -852,9 +852,30 @@ const makeManager = ( if (current === undefined) { return yield* Effect.fail(new ManagedStackNotFoundError({ stackId: update.stackId })); } + const metadata = { + versions: update.launch.versions, + ...(update.launch.excludedServices === undefined + ? {} + : { excludedServices: update.launch.excludedServices }), + ...(update.launch.lastNotifiedUpdateFingerprint === undefined + ? {} + : { + lastNotifiedUpdateFingerprint: update.launch.lastNotifiedUpdateFingerprint, + }), + }; + const launch: NonNullable = + current.launch === undefined || !("mode" in current.launch) + ? metadata + : current.launch.mode === "native" + ? { ...metadata, mode: "native" } + : { + ...metadata, + mode: "docker", + containerRuntime: current.launch.containerRuntime, + }; const next: ManagedStackDocument = { ...current, - launch: update.launch, + launch, updatedAt: now(), }; yield* store.write(next); @@ -976,9 +997,16 @@ const makeManager = ( if (current === undefined) return { outcome: "already-absent", stackId }; if ("outcome" in current) return current; yield* acquisition.setState("deleting", false); - yield* dockerForceRemove( - SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), - ); + if ( + current.launch !== undefined && + "mode" in current.launch && + current.launch.mode === "docker" + ) { + yield* dockerForceRemove( + current.launch.containerRuntime, + SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), + ); + } const { runtime: _runtime, ...withoutRuntime } = current; const deleting = { ...withoutRuntime, lifecycle: "deleting" as const, updatedAt: now() }; yield* store.write(deleting); diff --git a/packages/stack/src/node.ts b/packages/stack/src/node.ts index 8be38e7ce1..49c37accad 100644 --- a/packages/stack/src/node.ts +++ b/packages/stack/src/node.ts @@ -2,9 +2,12 @@ import { NodeServices } from "@effect/platform-node"; import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { BinaryResolver } from "./BinaryResolver.ts"; +import { selectStackRuntime } from "./ContainerRuntime.ts"; import { createStack as createStackCore, type StackHandle } from "./createStack.ts"; +import { toStackError } from "./errors.ts"; import { prefetch as prefetchEffect, + type PrefetchEffectOptions, type PrefetchOptions, type PrefetchResult, } from "./prefetch.ts"; @@ -20,16 +23,30 @@ import type { StackConfig } from "./StackConfig.ts"; */ export async function createStack(config?: StackConfig): Promise { - return createStackCore(config, platformFactory); + const runtime = await Effect.runPromise( + selectStackRuntime(config?.mode).pipe(Effect.provide(NodeServices.layer)), + ).catch((error: unknown) => { + throw toStackError(error); + }); + return createStackCore(config, platformFactory, runtime); } export async function prefetch(options?: PrefetchOptions): Promise { + const runtime = await Effect.runPromise( + selectStackRuntime(options?.mode).pipe(Effect.provide(NodeServices.layer)), + ).catch((error: unknown) => { + throw toStackError(error); + }); const resolverLayer = BinaryResolver.make(defaultCacheRoot()).pipe( Layer.provide(FetchHttpClient.layer), ); const preparationLayer = StackPreparation.layer.pipe(Layer.provide(resolverLayer)); + const resolvedOptions: PrefetchEffectOptions = + runtime.mode === "native" + ? { ...options, mode: "native" } + : { ...options, mode: "docker", containerRuntime: runtime.containerRuntime }; return Effect.runPromise( - prefetchEffect(options).pipe( + prefetchEffect(resolvedOptions).pipe( Effect.provide(preparationLayer), Effect.provide(NodeServices.layer), ), diff --git a/packages/stack/src/prefetch.ts b/packages/stack/src/prefetch.ts index b68d4b5f4d..c4a26af9fc 100644 --- a/packages/stack/src/prefetch.ts +++ b/packages/stack/src/prefetch.ts @@ -1,6 +1,6 @@ import { Effect } from "effect"; -import type { ChecksumMismatchError } from "./errors.ts"; -import type { DockerPullError } from "./errors.ts"; +import type { ContainerRuntime } from "./ContainerRuntime.ts"; +import type { StackPreparationError } from "./StackPreparation.ts"; import { type PreparedStackArtifacts, type ServiceResolution, @@ -9,7 +9,18 @@ import { import { StackPreparation } from "./StackPreparation.ts"; import type { ServiceName } from "./ServiceName.ts"; -export interface PrefetchOptions extends StackPreparationInput {} +export interface PrefetchOptions { + readonly versions?: StackPreparationInput["versions"]; + readonly services?: StackPreparationInput["services"]; + readonly enabledServices?: StackPreparationInput["enabledServices"]; + readonly mode?: "native" | "docker"; +} + +export type PrefetchEffectOptions = Omit & + ( + | { readonly mode?: "native"; readonly containerRuntime?: never } + | { readonly mode: "docker"; readonly containerRuntime: ContainerRuntime } + ); export type PrefetchResult = Partial>; @@ -17,9 +28,11 @@ const toPrefetchResult = (artifacts: PreparedStackArtifacts): PrefetchResult => artifacts.resolutions; export const prefetch = ( - options?: PrefetchOptions, -): Effect.Effect => + options?: PrefetchEffectOptions, +): Effect.Effect => Effect.gen(function* () { const preparation = yield* StackPreparation; - return yield* preparation.prepare(options).pipe(Effect.map(toPrefetchResult)); + const input: StackPreparationInput = + options?.mode === "docker" ? options : { ...options, mode: "native" }; + return yield* preparation.prepare(input).pipe(Effect.map(toPrefetchResult)); }); diff --git a/packages/stack/src/prefetch.unit.test.ts b/packages/stack/src/prefetch.unit.test.ts index 1cf509d074..881881c38e 100644 --- a/packages/stack/src/prefetch.unit.test.ts +++ b/packages/stack/src/prefetch.unit.test.ts @@ -1,22 +1,19 @@ import { describe, expect, test } from "vitest"; -import { Deferred, Effect, Layer, Sink, Stream } from "effect"; +import { Deferred, Effect, Fiber, Layer, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; -import { BinaryResolver } from "./BinaryResolver.ts"; -import { DockerPullError } from "./errors.ts"; +import { BinaryNotFoundError, DockerPullError } from "./errors.ts"; import { prefetch } from "./prefetch.ts"; import { ServiceDownloadFinished, ServiceDownloadStarted, + PreparationCompleted, StackPreparation, } from "./StackPreparation.ts"; -import { prepareAssetsWithDependencies } from "./StackPreparation.ts"; import { DEFAULT_VERSIONS, SERVICE_NAMES } from "./versions.ts"; const encoder = new TextEncoder(); -const defaultAuthEcrImage = `public.ecr.aws/supabase/gotrue:v${DEFAULT_VERSIONS.auth}`; -const defaultAuthDockerHubImage = `supabase/gotrue:v${DEFAULT_VERSIONS.auth}`; -const defaultAuthGhcrImage = `ghcr.io/supabase/gotrue:v${DEFAULT_VERSIONS.auth}`; +const defaultAuthGhcrImage = `ghcr.io/supabase/cli/auth:${DEFAULT_VERSIONS.auth}`; interface SpawnResult { readonly exitCode: number; @@ -40,12 +37,7 @@ function mockSequenceSpawner(results: ReadonlyArray) { index += 1; const exitDeferred = yield* Deferred.make(); - yield* Effect.forkDetach( - Effect.andThen( - Effect.sleep("1 millis"), - Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)), - ), - ); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(2000 + index), @@ -72,7 +64,7 @@ function mockSequenceSpawner(results: ReadonlyArray) { } describe("prefetch", () => { - test("prefetches all services by default", async () => { + test("prefetches every native-capable service by default in native mode", async () => { const resolver = mockBinaryResolver(); const spawner = mockSequenceSpawner( Array.from({ length: SERVICE_NAMES.length }, () => ({ @@ -87,18 +79,18 @@ describe("prefetch", () => { const result = await Effect.runPromise(prefetch().pipe(Effect.provide(layer))); - expect(Object.keys(result).sort()).toEqual([...SERVICE_NAMES].sort()); + expect(Object.keys(result).sort()).toEqual(["auth", "postgres", "postgrest"]); }); - test("falls back to Docker Hub after ECR rate limiting", async () => { + test("preparation fails with DockerPullError when the canonical image fails", async () => { const resolver = mockBinaryResolver({ failServices: ["auth"] }); + // One image inspect followed by one canonical pull. Preparation must fail + // rather than defer the pull to startup. const spawner = mockSequenceSpawner([ - { exitCode: 1 }, - { exitCode: 1 }, - { exitCode: 1 }, - { exitCode: 1, stderr: ["toomanyrequests: Rate exceeded"] }, - { exitCode: 1, stderr: ["toomanyrequests: Rate exceeded"] }, - { exitCode: 0 }, + { exitCode: 1, stderr: ["manifest unknown"] }, + { exitCode: 1, stderr: ["manifest unknown"] }, + { exitCode: 1, stderr: ["manifest unknown"] }, + { exitCode: 1, stderr: ["manifest unknown"] }, ]); const layer = StackPreparation.layer.pipe( @@ -106,199 +98,269 @@ describe("prefetch", () => { Layer.provide(spawner.layer), ); + const error = await Effect.runPromise( + prefetch({ mode: "docker", containerRuntime: "docker", services: ["auth"] }).pipe( + Effect.provide(layer), + Effect.flip, + ), + ); + + expect(error).toBeInstanceOf(DockerPullError); + expect(spawner.spawned).toHaveLength(2); + }); + + test("prefetching one service includes its required preparation dependencies", async () => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner([{ exitCode: 0 }, { exitCode: 0 }]); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + const result = await Effect.runPromise( - prefetch({ - mode: "docker", - services: ["auth"], - }).pipe(Effect.provide(layer)), + prefetch({ mode: "docker", containerRuntime: "docker", services: ["postgrest"] }).pipe( + Effect.provide(layer), + ), ); - expect(result.auth).toEqual({ - type: "docker", - image: defaultAuthDockerHubImage, - }); - expect( - spawner.spawned.filter((record) => record.args[0] === "pull").map((record) => record.args[1]), - ).toEqual([defaultAuthEcrImage, defaultAuthEcrImage, defaultAuthDockerHubImage]); + expect(Object.keys(result).sort()).toEqual(["postgres", "postgrest"]); }); - test("falls back to GHCR after ECR and Docker Hub fail", async () => { - const resolver = mockBinaryResolver({ failServices: ["auth"] }); - const spawner = mockSequenceSpawner([ - { exitCode: 1 }, - { exitCode: 1 }, - { exitCode: 1 }, - { exitCode: 1, stderr: ["manifest unknown"] }, - { exitCode: 1, stderr: ["toomanyrequests: Rate exceeded"] }, - { exitCode: 1, stderr: ["toomanyrequests: Rate exceeded"] }, - { exitCode: 0 }, + test("prefetching storage includes the companion it starts", async () => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner([{ exitCode: 0 }, { exitCode: 0 }, { exitCode: 0 }]); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + + const result = await Effect.runPromise( + prefetch({ mode: "docker", containerRuntime: "docker", services: ["storage"] }).pipe( + Effect.provide(layer), + ), + ); + + expect(Object.keys(result).sort()).toEqual(["imgproxy", "postgres", "storage"]); + }); + + test("prefetching uses the selected container runtime without pulling an owner", async () => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner([{ exitCode: 0 }]); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + + const result = await Effect.runPromise( + prefetch({ mode: "docker", containerRuntime: "podman", services: ["imgproxy"] }).pipe( + Effect.provide(layer), + ), + ); + + expect(Object.keys(result)).toEqual(["imgproxy"]); + expect(spawner.spawned).toEqual([ + { + command: "podman", + args: ["image", "inspect", `ghcr.io/supabase/cli/imgproxy:${DEFAULT_VERSIONS.imgproxy}`], + }, ]); + }); + test("does not prepare dependencies that are disabled in the stack", async () => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner([{ exitCode: 0 }, { exitCode: 0 }, { exitCode: 0 }]); const layer = StackPreparation.layer.pipe( Layer.provide(resolver.layer), Layer.provide(spawner.layer), ); const result = await Effect.runPromise( - prefetch({ - mode: "docker", - services: ["auth"], + Effect.gen(function* () { + const preparation = yield* StackPreparation; + return yield* preparation.prepare({ + mode: "docker", + containerRuntime: "docker", + services: ["studio"], + enabledServices: ["postgres", "pgmeta", "studio"], + }); }).pipe(Effect.provide(layer)), ); - expect(result.auth).toEqual({ + expect(Object.keys(result.resolutions).sort()).toEqual(["pgmeta", "postgres", "studio"]); + }); + + test("Docker mode uses Docker when a native artifact is unavailable", async () => { + const resolver = mockBinaryResolver({ + binaries: { postgres: "/cache/postgres/native" }, + }); + const spawner = mockSequenceSpawner([{ exitCode: 0 }]); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + + const result = await Effect.runPromise( + prefetch({ mode: "docker", containerRuntime: "docker", services: ["postgrest"] }).pipe( + Effect.provide(layer), + ), + ); + + expect(result.postgrest).toEqual({ type: "docker", - image: defaultAuthGhcrImage, + image: `ghcr.io/supabase/cli/postgrest:${DEFAULT_VERSIONS.postgrest}`, }); - expect( - spawner.spawned.filter((record) => record.args[0] === "pull").map((record) => record.args[1]), - ).toEqual([ - defaultAuthEcrImage, - defaultAuthDockerHubImage, - defaultAuthDockerHubImage, - defaultAuthGhcrImage, - ]); }); - test("preparation fails with DockerPullError when all registry candidates fail", async () => { - const resolver = mockBinaryResolver({ failServices: ["auth"] }); - // 3 image inspects (not cached locally) followed by a non-retryable pull for - // each registry candidate (ECR, Docker Hub, GHCR). "manifest unknown" is not a - // retryable pattern, so each candidate gets exactly one pull attempt: 3 + 3 = 6 - // spawns. With the whole fallback chain failing, preparation must fail rather - // than defer the pull to startup. - const spawner = mockSequenceSpawner([ - { exitCode: 1 }, - { exitCode: 1 }, - { exitCode: 1 }, - { exitCode: 1, stderr: ["manifest unknown"] }, - { exitCode: 1, stderr: ["manifest unknown"] }, - { exitCode: 1, stderr: ["manifest unknown"] }, - ]); - + test("native mode rejects services that have no native runtime", async () => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner([]); const layer = StackPreparation.layer.pipe( Layer.provide(resolver.layer), Layer.provide(spawner.layer), ); const error = await Effect.runPromise( - prefetch({ mode: "docker", services: ["auth"] }).pipe(Effect.provide(layer), Effect.flip), + prefetch({ mode: "native", services: ["edge-runtime"] }).pipe( + Effect.provide(layer), + Effect.flip, + ), ); - expect(error).toBeInstanceOf(DockerPullError); - // Guard the spawn-count assumption above: if the retry/candidate logic changes - // so more spawns occur, the mock would default the extras to success and mask - // the failure. Assert the exact count so that regresses loudly instead. - expect(spawner.spawned).toHaveLength(6); + expect(error).toBeInstanceOf(BinaryNotFoundError); }); - test("does not report downloading when the docker image is already cached locally", async () => { - const resolver = mockBinaryResolver({ failServices: ["auth"] }); + test("native mode does not fall back when a native artifact is unavailable", async () => { + const resolver = mockBinaryResolver({ failServices: ["postgrest"] }); const spawner = mockSequenceSpawner([{ exitCode: 0 }]); - const events: string[] = []; + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + + const error = await Effect.runPromise( + prefetch({ mode: "native", services: ["postgrest"] }).pipe( + Effect.provide(layer), + Effect.flip, + ), + ); + + expect(error).toBeInstanceOf(BinaryNotFoundError); + expect(spawner.spawned).toEqual([]); + }); + + test("prefetches pgmeta using its published container tag", async () => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner([{ exitCode: 0 }, { exitCode: 0 }]); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + const result = await Effect.runPromise( - Effect.gen(function* () { - const resolverService = yield* BinaryResolver; - const spawnerService = yield* ChildProcessSpawner.ChildProcessSpawner; - const artifacts = yield* prepareAssetsWithDependencies( - resolverService, - spawnerService, - { - mode: "docker", - services: ["auth"], - }, - (event) => - Effect.sync(() => { - if ( - event instanceof ServiceDownloadStarted || - event instanceof ServiceDownloadFinished - ) { - events.push(event._tag); - } - }), - ); - return artifacts.resolutions; - }).pipe(Effect.provide(resolver.layer), Effect.provide(spawner.layer)), + prefetch({ mode: "docker", containerRuntime: "docker", services: ["pgmeta"] }).pipe( + Effect.provide(layer), + ), ); - expect(result.auth).toEqual({ + expect(result.pgmeta).toEqual({ type: "docker", - image: defaultAuthEcrImage, + image: "ghcr.io/supabase/cli/pgmeta:v0.98.0", }); - expect(events).toEqual([]); }); - test("reports per-service download finished events as each service completes", async () => { - const resolver = mockBinaryResolver({ - downloadedServices: ["postgres", "postgrest", "auth"], - downloadDelaysMs: { - postgres: 10, - auth: 30, - postgrest: 50, - }, - }); - const events: string[] = []; - - await Effect.runPromise( + test("does not report downloading when the docker image is already cached locally", async () => { + const resolver = mockBinaryResolver({ failServices: ["auth"] }); + const spawner = mockSequenceSpawner([{ exitCode: 0 }]); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + const result = await Effect.runPromise( Effect.gen(function* () { - const resolverService = yield* BinaryResolver; - const artifacts = yield* prepareAssetsWithDependencies( - resolverService, - {} as ChildProcessSpawner.ChildProcessSpawner["Service"], - { - mode: "native", - services: ["postgres", "postgrest", "auth"], - }, - (event) => - Effect.sync(() => { - switch (event._tag) { - case "ServiceDownloadStarted": - case "ServiceDownloadFinished": - events.push(`${event._tag}:${event.service}`); - break; - case "PreparationCompleted": - events.push("PreparationCompleted"); - break; - } - }), + const preparation = yield* StackPreparation; + const streamEvents = yield* preparation + .prepareEvents({ mode: "docker", containerRuntime: "docker", services: ["auth"] }) + .pipe(Stream.runCollect); + const downloadEvents = streamEvents.flatMap((event) => + event instanceof ServiceDownloadStarted || event instanceof ServiceDownloadFinished + ? [event._tag] + : [], ); - expect(Object.keys(artifacts.resolutions)).toEqual(["postgres", "postgrest", "auth"]); - }).pipe(Effect.provide(resolver.layer)), + const completed = streamEvents.find((event) => event instanceof PreparationCompleted); + expect(downloadEvents).toEqual([]); + return completed instanceof PreparationCompleted ? completed.artifacts.resolutions : {}; + }).pipe(Effect.provide(layer)), ); - expect(events.slice(0, 3)).toEqual([ - "ServiceDownloadStarted:postgres", - "ServiceDownloadStarted:postgrest", - "ServiceDownloadStarted:auth", - ]); - expect(events.slice(3, 6).sort()).toEqual([ - "ServiceDownloadFinished:auth", - "ServiceDownloadFinished:postgres", - "ServiceDownloadFinished:postgrest", - ]); - expect(events.at(-1)).toBe("PreparationCompleted"); + expect(result.auth).toEqual({ + type: "docker", + image: defaultAuthGhcrImage, + }); }); - test("uses docker for edge-runtime in auto mode even when a native binary exists", async () => { + test("uses Docker for every service in Docker mode", async () => { const resolver = mockBinaryResolver(); const spawner = mockSequenceSpawner([{ exitCode: 0 }]); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); const result = await Effect.runPromise( Effect.gen(function* () { - const resolverService = yield* BinaryResolver; - const spawnerService = yield* ChildProcessSpawner.ChildProcessSpawner; - const artifacts = yield* prepareAssetsWithDependencies(resolverService, spawnerService, { - mode: "auto", + const preparation = yield* StackPreparation; + const artifacts = yield* preparation.prepare({ + mode: "docker", + containerRuntime: "docker", services: ["edge-runtime"], }); return artifacts.resolutions; - }).pipe(Effect.provide(resolver.layer), Effect.provide(spawner.layer)), + }).pipe(Effect.provide(layer)), ); expect(result["edge-runtime"]).toEqual({ type: "docker", - image: `public.ecr.aws/supabase/edge-runtime:v${DEFAULT_VERSIONS["edge-runtime"]}`, + image: `ghcr.io/supabase/cli/edge-runtime:${DEFAULT_VERSIONS["edge-runtime"]}`, }); expect(resolver.resolved).toEqual([]); }); + + test("concurrent prefetches share one materialization and return the same result", async () => { + const [result, resolved] = await Effect.runPromise( + Effect.gen(function* () { + const preparationStarted = yield* Deferred.make(); + const releasePreparation = yield* Deferred.make(); + const resolver = mockBinaryResolver({ + downloadedServices: ["auth"], + beforeResolve: ({ service }) => + service === "auth" + ? Deferred.succeed(preparationStarted, undefined).pipe( + Effect.andThen(Deferred.await(releasePreparation)), + ) + : Effect.void, + }); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(mockSequenceSpawner([]).layer), + ); + return yield* Effect.gen(function* () { + const first = yield* prefetch({ mode: "native", services: ["auth"] }).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(preparationStarted); + const second = yield* prefetch({ mode: "native", services: ["auth"] }).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(releasePreparation, undefined); + return [ + yield* Effect.all([Fiber.join(first), Fiber.join(second)]), + resolver.resolved, + ] as const; + }).pipe(Effect.provide(layer)); + }), + ); + + expect(result[0]).toEqual(result[1]); + expect(resolved.filter(({ service }) => service === "auth")).toHaveLength(1); + }); }); diff --git a/packages/stack/src/services/analytics.ts b/packages/stack/src/services/analytics.ts index e4039a989e..6f4bcfcf03 100644 --- a/packages/stack/src/services/analytics.ts +++ b/packages/stack/src/services/analytics.ts @@ -1,10 +1,14 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerPortMapArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerAnalyticsOptions { +interface DockerAnalyticsOptions extends ContainerRuntimeOptions { readonly image: string; readonly identity: StackIdentity; readonly hostPort: number; @@ -69,23 +73,13 @@ export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): Servic } return dockerRunService({ + runtime: opts.runtime, name: "analytics", identity: opts.identity, image: opts.image, networkArgs: dockerPortMapArgs(opts.platformOs, [ { host: opts.hostPort, container: ANALYTICS_CONTAINER_PORT }, ]), - entrypoint: "sh", - cmd: [ - "-c", - // migrate && start: a failed migrate exits the container and the - // unless-stopped restart retries until the db is ready (supabase/cli#6088). - `cat <<'EOF' > /tmp/run.sh && sh /tmp/run.sh -./logflare eval Logflare.Release.migrate && -./logflare start --sname logflare -EOF -`, - ], env, dependencies: opts.dependencies, healthCheck: analyticsHealthCheck(opts.hostPort), diff --git a/packages/stack/src/services/auth.ts b/packages/stack/src/services/auth.ts index 28fa6ddf98..111c07335b 100644 --- a/packages/stack/src/services/auth.ts +++ b/packages/stack/src/services/auth.ts @@ -2,7 +2,11 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; interface AuthServiceOptions { readonly dbPort: number; @@ -22,7 +26,7 @@ interface NativeAuthOptions extends AuthServiceOptions { readonly binPath: string; } -interface DockerAuthOptions extends AuthServiceOptions { +interface DockerAuthOptions extends AuthServiceOptions, ContainerRuntimeOptions { readonly image: string; readonly dbHost: string; readonly platformOs: string; @@ -71,7 +75,7 @@ const authHealthCheck = (port: number) => ({ export const makeAuthServiceNative = (opts: NativeAuthOptions): ServiceDef => ({ name: "auth", - command: `${opts.binPath}/auth`, + command: `${opts.binPath}/bin/auth`, env: authEnv(opts), dependencies: opts.dependencies, healthCheck: authHealthCheck(opts.authPort), @@ -82,6 +86,7 @@ export const makeAuthServiceNative = (opts: NativeAuthOptions): ServiceDef => ({ export const makeAuthServiceDocker = (opts: DockerAuthOptions): ServiceDef => { const env = authEnv(opts, opts.dbHost); return dockerRunService({ + runtime: opts.runtime, name: "auth", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/docker-cleanup.ts b/packages/stack/src/services/docker-cleanup.ts index c96c7af6b9..69c13c8860 100644 --- a/packages/stack/src/services/docker-cleanup.ts +++ b/packages/stack/src/services/docker-cleanup.ts @@ -1,27 +1,116 @@ import type { ExternalCleanupAction } from "@supabase/process-compose"; import { execFileSync } from "node:child_process"; import { Effect } from "effect"; +import type { ContainerRuntime } from "../ContainerRuntime.ts"; -export const dockerServiceCleanup = (containerName: string): Effect.Effect => +export interface DockerDataOwnershipCleanup { + readonly runtime: ContainerRuntime; + readonly image: string; + readonly hostPath: string; + readonly containerPath: string; + readonly uid: number; + readonly gid: number; + /** Remove the host path after restoring its ownership (orphan cleanup only). */ + readonly removeHostPath?: boolean; +} + +const ownershipArgs = (opts: DockerDataOwnershipCleanup): ReadonlyArray => [ + "run", + "--rm", + "--user", + "0", + "-v", + `${opts.hostPath}:${opts.containerPath}`, + "--entrypoint", + "/usr/bin/sh", + opts.image, + "-ec", + `busybox chown -R ${opts.uid}:${opts.gid} ${opts.containerPath}`, +]; + +const restoreOwnership = (opts: DockerDataOwnershipCleanup): void => { + execFileSync(opts.runtime, ownershipArgs(opts), { + stdio: "ignore", + timeout: 30_000, + }); +}; + +const shellQuote = (value: string): string => `'${value.replaceAll("'", `'"'"'`)}'`; + +const orphanCleanupScript = ( + containerName: string, + opts: DockerDataOwnershipCleanup, +): ReadonlyArray => { + const ownershipCommand = [ + shellQuote(opts.runtime), + "run", + "--rm", + "--user", + "0", + "-v", + `"$1:${opts.containerPath}"`, + "--entrypoint", + shellQuote("/usr/bin/sh"), + shellQuote(opts.image), + "-ec", + shellQuote(`busybox chown -R ${opts.uid}:${opts.gid} ${opts.containerPath}`), + ].join(" "); + + const script = [ + `${shellQuote(opts.runtime)} rm -f ${shellQuote(containerName)} >/dev/null 2>&1 || true`, + ownershipCommand, + ...(opts.removeHostPath ? ['rm -rf -- "$1"'] : []), + ].join("\n"); + + // `$0` is the descriptive shell name and `$1` is the path supplied by the + // caller. Passing the path as an argument avoids interpolating host paths in + // the script while keeping the ownership and removal operations ordered. + return ["-ec", script, "supabase-stack-docker-cleanup", opts.hostPath]; +}; + +export const dockerServiceCleanup = ( + runtime: ContainerRuntime, + containerName: string, + ownership?: DockerDataOwnershipCleanup, +): Effect.Effect => Effect.sync(() => { try { - execFileSync("docker", ["rm", "-f", containerName], { + execFileSync(runtime, ["rm", "-f", containerName], { stdio: "ignore", timeout: 5_000, }); } catch {} + + if (ownership !== undefined) { + // Preserve the cleanup failure so process-compose records it in the + // service log. A missing container is harmless, but a failed ownership + // bridge leaves the mounted data unusable by native mode. + restoreOwnership(ownership); + } }); export const dockerServiceOrphanCleanup = ( + runtime: ContainerRuntime, containerName: string, -): ReadonlyArray => [ - { - _tag: "RunCommand", - executable: "docker", - args: ["rm", "-f", containerName], - timeoutMs: 5_000, - }, -]; + ownership?: DockerDataOwnershipCleanup, +): ReadonlyArray => + ownership === undefined + ? [ + { + _tag: "RunCommand", + executable: runtime, + args: ["rm", "-f", containerName], + timeoutMs: 5_000, + }, + ] + : [ + { + _tag: "RunCommand", + executable: "/bin/sh", + args: orphanCleanupScript(containerName, ownership), + timeoutMs: 30_000, + }, + ]; export const removePathOnOrphanCleanup = ( path: string, diff --git a/packages/stack/src/services/edge-runtime.ts b/packages/stack/src/services/edge-runtime.ts index 168a14cd26..a4dfdb89e5 100644 --- a/packages/stack/src/services/edge-runtime.ts +++ b/packages/stack/src/services/edge-runtime.ts @@ -3,7 +3,12 @@ import { join } from "node:path"; import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + hostHttpHealthCheck, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import bootstrapSource from "./edge-runtime-main.ts" with { type: "text" }; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -21,7 +26,7 @@ interface NativeEdgeRuntimeOptions extends EdgeRuntimeOptions { readonly binPath: string; } -interface DockerEdgeRuntimeOptions extends EdgeRuntimeOptions { +interface DockerEdgeRuntimeOptions extends EdgeRuntimeOptions, ContainerRuntimeOptions { readonly image: string; readonly identity: StackIdentity; readonly platformOs: string; @@ -86,6 +91,7 @@ export const makeEdgeRuntimeServiceDocker = (opts: DockerEdgeRuntimeOptions): Se const bootstrapDir = ensureBootstrapScript(opts.runtimeRoot); return dockerRunService({ + runtime: opts.runtime, name: "edge-runtime", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/imgproxy.ts b/packages/stack/src/services/imgproxy.ts index 06d1fcd64f..2c9acf0bc7 100644 --- a/packages/stack/src/services/imgproxy.ts +++ b/packages/stack/src/services/imgproxy.ts @@ -1,10 +1,15 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + hostHttpHealthCheck, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerImgproxyOptions { +interface DockerImgproxyOptions extends ContainerRuntimeOptions { readonly image: string; readonly port: number; readonly identity: StackIdentity; @@ -22,6 +27,7 @@ const imgproxyHealthCheck = (port: number): ServiceDef["healthCheck"] => export const makeImgproxyServiceDocker = (opts: DockerImgproxyOptions): ServiceDef => dockerRunService({ + runtime: opts.runtime, name: "imgproxy", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/mailpit.ts b/packages/stack/src/services/mailpit.ts index a57d51b60c..193f408e02 100644 --- a/packages/stack/src/services/mailpit.ts +++ b/packages/stack/src/services/mailpit.ts @@ -1,10 +1,15 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + hostHttpHealthCheck, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerMailpitOptions { +interface DockerMailpitOptions extends ContainerRuntimeOptions { readonly image: string; readonly identity: StackIdentity; readonly webPort: number; @@ -21,6 +26,7 @@ const mailpitHealthCheck = (port: number): ServiceDef["healthCheck"] => export const makeMailpitServiceDocker = (opts: DockerMailpitOptions): ServiceDef => dockerRunService({ + runtime: opts.runtime, name: "mailpit", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/pgmeta.ts b/packages/stack/src/services/pgmeta.ts index b4a95779c6..fe5d74631b 100644 --- a/packages/stack/src/services/pgmeta.ts +++ b/packages/stack/src/services/pgmeta.ts @@ -1,10 +1,14 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerPgmetaOptions { +interface DockerPgmetaOptions extends ContainerRuntimeOptions { readonly image: string; readonly identity: StackIdentity; readonly port: number; @@ -27,6 +31,7 @@ const pgmetaHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ export const makePgmetaServiceDocker = (opts: DockerPgmetaOptions): ServiceDef => dockerRunService({ + runtime: opts.runtime, name: "pgmeta", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/pooler.ts b/packages/stack/src/services/pooler.ts index d638ed2029..10e53633bb 100644 --- a/packages/stack/src/services/pooler.ts +++ b/packages/stack/src/services/pooler.ts @@ -1,12 +1,16 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerPortMapArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; type PoolMode = "transaction" | "session"; -interface DockerPoolerOptions { +interface DockerPoolerOptions extends ContainerRuntimeOptions { readonly image: string; readonly identity: StackIdentity; readonly hostAdminPort: number; @@ -70,6 +74,7 @@ end`; export const makePoolerServiceDocker = (opts: DockerPoolerOptions): ServiceDef => (() => { return dockerRunService({ + runtime: opts.runtime, name: "pooler", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/postgres-init.ts b/packages/stack/src/services/postgres-init.ts index 63917352b1..4447226169 100644 --- a/packages/stack/src/services/postgres-init.ts +++ b/packages/stack/src/services/postgres-init.ts @@ -1,5 +1,6 @@ import type { ServiceDef } from "@supabase/process-compose"; -import type { ServiceDependency } from "./service-utils.ts"; +import { dockerContainerName, type StackIdentity } from "../StackIdentity.ts"; +import type { ContainerRuntimeOptions, ServiceDependency } from "./service-utils.ts"; interface PostgresInitOptions { readonly postgresDir: string; @@ -12,6 +13,15 @@ interface PostgresInitOptions { readonly dependencies: ReadonlyArray; } +interface DockerPostgresInitOptions extends ContainerRuntimeOptions { + readonly dbPort: number; + readonly jwtSecret: string; + readonly jwtExpiry: number; + readonly autoExposeNewTables: boolean; + readonly identity: StackIdentity; + readonly dependencies: ReadonlyArray; +} + /** * SQL that matches what Studio runs at cloud project creation when "Default privileges for new * entities" is off. Revokes the default GRANTs installed by the bundled initial schema so new @@ -27,6 +37,53 @@ alter default privileges for role postgres in schema public revoke execute on functions from anon, authenticated, service_role; `.trim(); +const dockerPostgresSchemaSql = (opts: DockerPostgresInitOptions) => + ` +\\set jwt_secret \`echo "$JWT_SECRET"\` +\\set jwt_exp \`echo "$JWT_EXP"\` +ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; +ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; +ALTER USER postgres WITH PASSWORD 'postgres'; +ALTER USER authenticator WITH PASSWORD 'postgres'; +ALTER USER supabase_auth_admin WITH PASSWORD 'postgres'; +ALTER USER supabase_storage_admin WITH PASSWORD 'postgres'; +ALTER USER supabase_replication_admin WITH PASSWORD 'postgres'; +ALTER USER supabase_read_only_user WITH PASSWORD 'postgres'; +CREATE SCHEMA IF NOT EXISTS _realtime; +ALTER SCHEMA _realtime OWNER TO postgres; +${opts.autoExposeNewTables ? "" : REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL} +SELECT 'CREATE DATABASE _supabase WITH OWNER postgres' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = '_supabase')\\gexec +\\connect _supabase +CREATE SCHEMA IF NOT EXISTS _analytics; +ALTER SCHEMA _analytics OWNER TO postgres; +CREATE SCHEMA IF NOT EXISTS _supavisor; +ALTER SCHEMA _supavisor OWNER TO postgres; +`.trim(); + +export const makePostgresInitServiceDocker = (opts: DockerPostgresInitOptions): ServiceDef => ({ + name: "postgres-init", + command: opts.runtime, + args: [ + "exec", + "-e", + "PGPASSWORD=postgres", + "-e", + `JWT_SECRET=${opts.jwtSecret}`, + "-e", + `JWT_EXP=${opts.jwtExpiry}`, + dockerContainerName("postgres", opts.identity.key), + "sh", + "-c", + `/opt/postgres/bin/psql -h 127.0.0.1 -p ${opts.dbPort} -v ON_ERROR_STOP=1 --no-password --no-psqlrc -U supabase_admin -d postgres <<'EOSQL' +${dockerPostgresSchemaSql(opts)} +EOSQL`, + ], + dependencies: opts.dependencies, + supervision: {}, + restart: "no", +}); + export const makePostgresInitService = (opts: PostgresInitOptions): ServiceDef => { const pgBinDir = `${opts.postgresDir}/bin`; const pgLibDir = `${opts.postgresDir}/lib`; diff --git a/packages/stack/src/services/postgres.ts b/packages/stack/src/services/postgres.ts index 910b76d544..a42301d17d 100644 --- a/packages/stack/src/services/postgres.ts +++ b/packages/stack/src/services/postgres.ts @@ -1,12 +1,12 @@ -import { mkdirSync, writeFileSync } from "node:fs"; import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import { dockerContainerName, type StackIdentity } from "../StackIdentity.ts"; -import { removePathOnOrphanCleanup } from "./docker-cleanup.ts"; +import { removePathOnOrphanCleanup, type DockerDataOwnershipCleanup } from "./docker-cleanup.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; import { dockerExecHealthCheck, dockerRunService, + type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; @@ -19,15 +19,11 @@ interface PostgresServiceOptions { interface NativePostgresOptions extends PostgresServiceOptions { readonly binPath: string; - /** When true, patches postgres to listen on all interfaces so Docker containers can connect. */ - readonly dockerAccessible?: boolean; } -interface DockerPostgresOptions extends PostgresServiceOptions { +interface DockerPostgresOptions extends PostgresServiceOptions, ContainerRuntimeOptions { readonly image: string; readonly platformOs: string; - readonly jwtSecret: string; - readonly jwtExpiry: number; readonly identity: StackIdentity; readonly cleanupDataDirOnExit?: boolean; } @@ -40,13 +36,9 @@ const postgresEnv = (opts: NativePostgresOptions): Record => ({ TZDIR: "/var/db/timezone/zoneinfo", }); -const postgresDockerEnv = (opts: DockerPostgresOptions): Record => ({ - POSTGRES_PASSWORD: "postgres", - JWT_SECRET: opts.jwtSecret, - JWT_EXP: String(opts.jwtExpiry), -}); - const NATIVE_POSTGRES_RUNTIME_ARGS = [ + "-c", + "listen_addresses=127.0.0.1", "-c", "wal_level=logical", "-c", @@ -55,35 +47,12 @@ const NATIVE_POSTGRES_RUNTIME_ARGS = [ "max_replication_slots=5", ] as const; +const postgresGetKeyScript = (binPath: string): string => + `${binPath}/share/supabase-cli/config/pgsodium_getkey.sh`; + const orphanCleanup = (opts: PostgresServiceOptions) => opts.cleanupDataDirOnExit ? removePathOnOrphanCleanup(opts.dataDir) : []; -const DOCKER_POSTGRES_SCHEMA_SQL = `\\set pgpass \`echo "$PGPASSWORD"\` -\\set jwt_secret \`echo "$JWT_SECRET"\` -\\set jwt_exp \`echo "$JWT_EXP"\` -ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; -ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; -ALTER USER postgres WITH PASSWORD :'pgpass'; -ALTER USER authenticator WITH PASSWORD :'pgpass'; -ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; -ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass'; -ALTER USER supabase_replication_admin WITH PASSWORD :'pgpass'; -ALTER USER supabase_read_only_user WITH PASSWORD :'pgpass'; -create schema if not exists _realtime; -alter schema _realtime owner to postgres; -SELECT 'CREATE DATABASE _supabase WITH OWNER postgres' -WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = '_supabase')\\gexec -\\connect _supabase -create schema if not exists _analytics; -alter schema _analytics owner to postgres; -create schema if not exists _supavisor; -alter schema _supavisor owner to postgres;`; - -const dockerPostgresEntrypoint = (port: number) => - `cat <<'EOF' > /etc/postgresql.schema.sql && exec docker-entrypoint.sh postgres -D /etc/postgresql -p ${port} -${DOCKER_POSTGRES_SCHEMA_SQL} -EOF`; - const postgresHealthCheck = (binPath: string, port: number) => ({ probe: { _tag: "Exec" as const, @@ -98,71 +67,75 @@ const postgresHealthCheck = (binPath: string, port: number) => ({ }); /** - * Docker postgres health check using pg_isready inside the container. + * Docker postgres health check using the final postgres process and pg_isready + * inside the container. * - * TCP alone is insufficient because the supabase/postgres image accepts TCP - * connections during its init phase (running init scripts) but drops real - * queries with "unexpected EOF". We use `docker exec` to run pg_isready - * inside the container, which verifies postgres is accepting commands. + * The supabase/postgres image briefly accepts connections while its entrypoint + * runs initialization. During that phase PID 1 is still the shell and the + * temporary server is stopped before the final postgres process starts. Gate + * readiness on the final Postgres process name and pg_isready so dependents + * never race that handoff. `/proc/1/exe` is intentionally avoided because + * Linux container hardening can make that symlink unreadable across users. */ -const postgresDockerHealthCheck = (containerName: string, port: number) => - dockerExecHealthCheck(containerName, "pg_isready", ["-p", String(port), "-U", "postgres"], { - ...stackHealthBudgets.postgresDocker, - }); +const postgresDockerHealthCheck = ( + runtime: DockerPostgresOptions["runtime"], + containerName: string, + port: number, +) => + dockerExecHealthCheck( + runtime, + containerName, + "sh", + [ + "-ec", + `case "$(cat /proc/1/comm)" in postgres|.postgres-wrapp) pg_isready -h 127.0.0.1 -p ${port} -U postgres ;; *) exit 1 ;; esac`, + ], + { + ...stackHealthBudgets.postgresDocker, + }, + ); -export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => { - const initScript = `${opts.binPath}/share/supabase-cli/bin/supabase-postgres-init.sh`; +const postgresDockerOwnershipCleanup = ( + opts: DockerPostgresOptions, +): DockerDataOwnershipCleanup | undefined => { + if (opts.runtime !== "docker" || opts.platformOs !== "linux") { + return undefined; + } - if (opts.dockerAccessible) { - // Docker containers connect via host.docker.internal, which resolves to a gateway IP - // rather than 127.0.0.1. We create a per-run pg_hba.conf that allows those - // connections, and use postgres -c flags to override listen_addresses and hba_file. - // This avoids mutating the shared binary cache. - const customHbaPath = `${opts.dataDir}_pg_hba_docker.conf`; - mkdirSync(opts.dataDir, { recursive: true }); - writeFileSync( - customHbaPath, - [ - "local all all scram-sha-256", - "host all all 127.0.0.1/32 scram-sha-256", - "host all all ::1/128 scram-sha-256", - "host all all 0.0.0.0/0 scram-sha-256", - "", - ].join("\n"), - "utf8", - ); - - return { - name: "postgres", - command: "bash", - args: [ - initScript, - "-p", - String(opts.port), - ...NATIVE_POSTGRES_RUNTIME_ARGS, - "-c", - "listen_addresses=*", - "-c", - `hba_file=${customHbaPath}`, - ], - env: postgresEnv(opts), - dependencies: opts.dependencies, - healthCheck: postgresHealthCheck(opts.binPath, opts.port), - shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, - supervision: { - orphanCleanup: [ - ...orphanCleanup(opts), - ...removePathOnOrphanCleanup(customHbaPath, { recursive: false }), - ], - }, - restart: "unless-stopped", - }; + const uid = process.getuid?.(); + const gid = process.getgid?.(); + if (uid === undefined || gid === undefined) { + return undefined; } + return { + runtime: opts.runtime, + image: opts.image, + hostPath: opts.dataDir, + containerPath: "/var/lib/postgresql/data", + uid, + gid, + removeHostPath: opts.cleanupDataDirOnExit, + }; +}; + +export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => { + const initScript = `${opts.binPath}/share/supabase-cli/bin/supabase-postgres-init.sh`; + const getKeyScript = postgresGetKeyScript(opts.binPath); + return { name: "postgres", command: "bash", - args: [initScript, "-p", String(opts.port), ...NATIVE_POSTGRES_RUNTIME_ARGS], + args: [ + initScript, + "-p", + String(opts.port), + ...NATIVE_POSTGRES_RUNTIME_ARGS, + "-c", + `pgsodium.getkey_script=${getKeyScript}`, + "-c", + `vault.getkey_script=${getKeyScript}`, + ], env: postgresEnv(opts), dependencies: opts.dependencies, healthCheck: postgresHealthCheck(opts.binPath, opts.port), @@ -173,20 +146,55 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => }; export const makePostgresServiceDocker = (opts: DockerPostgresOptions): ServiceDef => { - const env = postgresDockerEnv(opts); const containerName = dockerContainerName("postgres", opts.identity.key); + const ownershipCleanup = postgresDockerOwnershipCleanup(opts); + const runtimeArgs = [ + "-p", + String(opts.port), + "-c", + "listen_addresses=*", + "-c", + "pgsodium.getkey_script=/opt/postgres/share/supabase-cli/config/pgsodium_getkey.sh", + "-c", + "vault.getkey_script=/opt/postgres/share/supabase-cli/config/pgsodium_getkey.sh", + ] as const; + + // Native initialization permits only loopback clients. When reusing that + // data directory in Docker, route through a temporary HBA copy that adds the + // container network rule without mutating the persisted native config. + const runEntrypoint = (args: string): string => + opts.runtime === "docker" && opts.platformOs === "linux" + ? `exec busybox su -s /usr/bin/sh nonroot -c 'exec /usr/local/bin/entry.sh ${args}'` + : `exec /usr/local/bin/entry.sh ${args}`; + + const command = `${ + opts.runtime === "docker" && opts.platformOs === "linux" + ? "busybox chown -R 65532:65532 /var/lib/postgresql/data\n" + : "" + }if [ -s /var/lib/postgresql/data/PG_VERSION ]; then + cp /var/lib/postgresql/data/pg_hba.conf /tmp/supabase-cli-pg_hba.conf + printf '\\nhost all all all scram-sha-256\\n' >> /tmp/supabase-cli-pg_hba.conf + busybox chown 65532:65532 /tmp/supabase-cli-pg_hba.conf + ${runEntrypoint(`-c hba_file=/tmp/supabase-cli-pg_hba.conf ${runtimeArgs.join(" ")}`)} +else + ${runEntrypoint(runtimeArgs.join(" "))} +fi`; + return dockerRunService({ + runtime: opts.runtime, name: "postgres", identity: opts.identity, image: opts.image, networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), volumes: [`${opts.dataDir}:/var/lib/postgresql/data`], - env, - entrypoint: "sh", - cmd: ["-c", dockerPostgresEntrypoint(opts.port)], + env: { POSTGRES_PASSWORD: "postgres" }, + ...(opts.runtime === "docker" && opts.platformOs === "linux" ? { user: "0" } : {}), + entrypoint: "/usr/bin/sh", + cmd: ["-c", command], dependencies: opts.dependencies, - healthCheck: postgresDockerHealthCheck(containerName, opts.port), + healthCheck: postgresDockerHealthCheck(opts.runtime, containerName, opts.port), shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, - orphanCleanup: orphanCleanup(opts), + cleanup: ownershipCleanup, + orphanCleanup: ownershipCleanup === undefined ? orphanCleanup(opts) : [], }); }; diff --git a/packages/stack/src/services/postgrest.ts b/packages/stack/src/services/postgrest.ts index e9aea4ce05..bfb211b8c1 100644 --- a/packages/stack/src/services/postgrest.ts +++ b/packages/stack/src/services/postgrest.ts @@ -2,7 +2,11 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; interface PostgrestServiceOptions { readonly dbPort: number; @@ -18,7 +22,7 @@ interface NativePostgrestOptions extends PostgrestServiceOptions { readonly binPath: string; } -interface DockerPostgrestOptions extends PostgrestServiceOptions { +interface DockerPostgrestOptions extends PostgrestServiceOptions, ContainerRuntimeOptions { readonly image: string; readonly dbHost: string; readonly platformOs: string; @@ -52,7 +56,7 @@ const postgrestHealthCheck = (port: number) => ({ export const makePostgrestService = (opts: NativePostgrestOptions): ServiceDef => ({ name: "postgrest", - command: `${opts.binPath}/postgrest`, + command: `${opts.binPath}/bin/postgrest`, env: postgrestEnv(opts), dependencies: opts.dependencies, healthCheck: postgrestHealthCheck(opts.port), @@ -66,6 +70,7 @@ export const makePostgrestServiceDocker = (opts: DockerPostgrestOptions): Servic PGRST_ADMIN_SERVER_PORT: String(opts.adminPort), }; return dockerRunService({ + runtime: opts.runtime, name: "postgrest", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/realtime.ts b/packages/stack/src/services/realtime.ts index 32ac4d53a0..0783ed830f 100644 --- a/packages/stack/src/services/realtime.ts +++ b/packages/stack/src/services/realtime.ts @@ -1,10 +1,14 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerRealtimeOptions { +interface DockerRealtimeOptions extends ContainerRuntimeOptions { readonly image: string; readonly port: number; readonly identity: StackIdentity; @@ -39,6 +43,7 @@ const realtimeHealthCheck = (port: number, tenantId: string): ServiceDef["health export const makeRealtimeServiceDocker = (opts: DockerRealtimeOptions): ServiceDef => dockerRunService({ + runtime: opts.runtime, name: "realtime", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/service-utils.ts b/packages/stack/src/services/service-utils.ts index 230741cfde..59bbd91b5d 100644 --- a/packages/stack/src/services/service-utils.ts +++ b/packages/stack/src/services/service-utils.ts @@ -1,14 +1,23 @@ import type { ExternalCleanupAction, ServiceDef } from "@supabase/process-compose"; import type { ServiceName } from "../ServiceName.ts"; +import type { ContainerRuntime } from "../ContainerRuntime.ts"; import { dockerContainerName, STACK_ID_LABEL, type StackIdentity } from "../StackIdentity.ts"; -import { dockerServiceCleanup, dockerServiceOrphanCleanup } from "./docker-cleanup.ts"; +import { + dockerServiceCleanup, + dockerServiceOrphanCleanup, + type DockerDataOwnershipCleanup, +} from "./docker-cleanup.ts"; export interface ServiceDependency { readonly service: string; readonly condition: "healthy" | "completed"; } -interface DockerRunServiceOptions { +export interface ContainerRuntimeOptions { + readonly runtime: ContainerRuntime; +} + +interface DockerRunServiceOptions extends ContainerRuntimeOptions { readonly name: ServiceName; readonly identity: StackIdentity; readonly image: string; @@ -18,11 +27,14 @@ interface DockerRunServiceOptions { readonly cmd?: ReadonlyArray; readonly entrypoint?: string; readonly volumes?: ReadonlyArray; + readonly securityOptions?: ReadonlyArray; + readonly user?: string; readonly dependencies: ReadonlyArray; readonly healthCheck?: ServiceDef["healthCheck"]; readonly restart?: ServiceDef["restart"]; readonly shutdown?: ServiceDef["shutdown"]; readonly orphanCleanup?: ReadonlyArray; + readonly cleanup?: DockerDataOwnershipCleanup; } const envArgs = (env: Record): ReadonlyArray => @@ -44,6 +56,7 @@ export const hostHttpHealthCheck = ( }); export const dockerExecHealthCheck = ( + runtime: ContainerRuntime, containerName: string, command: string, args: ReadonlyArray, @@ -51,7 +64,7 @@ export const dockerExecHealthCheck = ( ): ServiceDef["healthCheck"] => ({ probe: { _tag: "Exec", - command: "docker", + command: runtime, args: ["exec", containerName, command, ...args], }, ...opts, @@ -71,6 +84,8 @@ export const dockerRunService = (opts: DockerRunServiceOptions): ServiceDef => { : ["--label", `${STACK_ID_LABEL}=${opts.identity.stackId}`]), ...(opts.networkArgs ?? []), ...(opts.volumes ?? []).flatMap((volume) => ["-v", volume]), + ...(opts.securityOptions ?? []).flatMap((option) => ["--security-opt", option]), + ...(opts.user === undefined ? [] : ["--user", opts.user]), ...(opts.entrypoint === undefined ? [] : ["--entrypoint", opts.entrypoint]), ...(opts.args ?? []), ...envArgs(opts.env ?? {}), @@ -80,14 +95,17 @@ export const dockerRunService = (opts: DockerRunServiceOptions): ServiceDef => { return { name: opts.name, - command: "docker", + command: opts.runtime, args: dockerArgs, dependencies: opts.dependencies, healthCheck: opts.healthCheck, shutdown: opts.shutdown, - cleanup: dockerServiceCleanup(containerName), + cleanup: dockerServiceCleanup(opts.runtime, containerName, opts.cleanup), supervision: { - orphanCleanup: [...dockerServiceOrphanCleanup(containerName), ...(opts.orphanCleanup ?? [])], + orphanCleanup: [ + ...dockerServiceOrphanCleanup(opts.runtime, containerName, opts.cleanup), + ...(opts.orphanCleanup ?? []), + ], }, restart: opts.restart ?? "unless-stopped", }; diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index 6259462de5..1c8fce7d31 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -47,17 +47,16 @@ describe("makePostgresService", () => { expect(def.name).toBe("postgres"); expect(def.command).toBe("bash"); - expect(def.args).toEqual([ + expect(def.args).toContain( `${POSTGRES_BIN_PATH}/share/supabase-cli/bin/supabase-postgres-init.sh`, - "-p", - "54322", - "-c", - "wal_level=logical", - "-c", - "max_wal_senders=5", - "-c", - "max_replication_slots=5", - ]); + ); + expect(def.args).toContain("listen_addresses=127.0.0.1"); + expect(def.args).toContain( + `pgsodium.getkey_script=${POSTGRES_BIN_PATH}/share/supabase-cli/config/pgsodium_getkey.sh`, + ); + expect(def.args).toContain( + `vault.getkey_script=${POSTGRES_BIN_PATH}/share/supabase-cli/config/pgsodium_getkey.sh`, + ); expect(def.env?.PGDATA).toBe("/tmp/supabase/data"); expect(def.env?.POSTGRES_PASSWORD).toBe("postgres"); expect(def.env?.DYLD_LIBRARY_PATH).toBe(`${POSTGRES_BIN_PATH}/lib`); @@ -95,6 +94,7 @@ describe("analyticsDockerRuntimeNetwork", () => { describe("makeStudioServiceDocker", () => { it("injects legacy keys, opaque keys, and S3 protocol credentials", () => { const def = makeStudioServiceDocker({ + runtime: "docker", image: dockerImageForService("studio", DEFAULT_VERSIONS.studio), identity: EPHEMERAL_IDENTITY, port: 54323, @@ -125,60 +125,14 @@ describe("makeStudioServiceDocker", () => { }); }); -describe("makePostgresService (dockerAccessible)", () => { - it("creates per-run pg_hba.conf instead of mutating shared cache", () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "stack-postgres-service-")); - const def = makePostgresService({ - binPath: POSTGRES_BIN_PATH, - dataDir: path.join(tempDir, "data"), - port: DB_PORT, - dockerAccessible: true, - cleanupDataDirOnExit: true, - dependencies: [], - }); - const customHbaPath = `${path.join(tempDir, "data")}_pg_hba_docker.conf`; - - try { - expect(def.name).toBe("postgres"); - expect(def.command).toBe("bash"); - expect(def.args).toEqual([ - `${POSTGRES_BIN_PATH}/share/supabase-cli/bin/supabase-postgres-init.sh`, - "-p", - "54322", - "-c", - "wal_level=logical", - "-c", - "max_wal_senders=5", - "-c", - "max_replication_slots=5", - "-c", - "listen_addresses=*", - "-c", - `hba_file=${customHbaPath}`, - ]); - expect(readFileSync(customHbaPath, "utf8")).toContain("0.0.0.0/0"); - expect(def.supervision).toEqual({ - orphanCleanup: [ - { _tag: "RemovePath", path: path.join(tempDir, "data") }, - { _tag: "RemovePath", path: customHbaPath, recursive: false }, - ], - }); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - rmSync(customHbaPath, { force: true }); - } - }); -}); - describe("makePostgresServiceDocker", () => { it("creates a docker-based postgres ServiceDef", () => { const def = makePostgresServiceDocker({ + runtime: "docker", image: dockerImageForService("postgres", DEFAULT_VERSIONS.postgres), dataDir: "/tmp/supabase/data", port: DB_PORT, platformOs: "linux", - jwtSecret: "test-jwt-secret-with-at-least-32-characters", - jwtExpiry: 3600, identity: EPHEMERAL_IDENTITY, dependencies: [], }); @@ -192,56 +146,19 @@ describe("makePostgresServiceDocker", () => { expect(def.args).toContain(`${DB_PORT}:${DB_PORT}`); expect(def.args).toContain(dockerImageForService("postgres", DEFAULT_VERSIONS.postgres)); expect(def.args).toContain("/tmp/supabase/data:/var/lib/postgresql/data"); - // Verify port is passed to postgres inside the container - expect(def.args?.[def.args.length - 1]).toContain(`-p ${DB_PORT}`); - // Health check uses docker exec + pg_isready inside the container (host has no postgres tools) - expect(def.healthCheck?.probe).toEqual({ - _tag: "Exec", - command: "docker", - args: [ - "exec", - `supabase-postgres-${API_PORT}`, - "pg_isready", - "-p", - "54322", - "-U", - "postgres", - ], - }); + expect(def.args).toContain("/usr/bin/sh"); + expect(def.args?.at(-2)).toBe("-c"); + // The Linux-compatible health gate distinguishes the final server from + // the image's temporary initialization server. + expect(def.healthCheck?.probe).toEqual( + expect.objectContaining({ _tag: "Exec", command: "docker" }), + ); + expect( + def.healthCheck?.probe._tag === "Exec" && def.healthCheck.probe.args.join(" "), + ).toContain("/proc/1/comm"); expect(def.dependencies).toEqual([]); expect(def.restart).toBe("unless-stopped"); - expect(def.supervision).toEqual({ - orphanCleanup: [ - { - _tag: "RunCommand", - executable: "docker", - args: ["rm", "-f", `supabase-postgres-${API_PORT}`], - timeoutMs: 5_000, - }, - ], - }); - }); - - it("bootstraps auxiliary databases and schemas used by docker-backed services", () => { - const def = makePostgresServiceDocker({ - image: dockerImageForService("postgres", DEFAULT_VERSIONS.postgres), - dataDir: "/tmp/supabase/data", - port: DB_PORT, - platformOs: "linux", - jwtSecret: "test-jwt-secret-with-at-least-32-characters", - jwtExpiry: 3600, - identity: EPHEMERAL_IDENTITY, - dependencies: [], - }); - - const script = def.args?.[def.args.length - 1] as string; - expect(script).toContain("CREATE DATABASE _supabase WITH OWNER postgres"); - expect(script).toContain( - "WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = '_supabase')", - ); - expect(script).toContain("\\connect _supabase"); - expect(script).toContain("create schema if not exists _analytics;"); - expect(script).toContain("create schema if not exists _supavisor;"); + expect(def.supervision?.orphanCleanup).toBeDefined(); }); }); @@ -259,7 +176,7 @@ describe("makePostgrestService", () => { }); expect(def.name).toBe("postgrest"); - expect(def.command).toBe(`${POSTGREST_BIN_PATH}/postgrest`); + expect(def.command).toBe(`${POSTGREST_BIN_PATH}/bin/postgrest`); expect(def.env?.PGRST_DB_URI).toBe( `postgresql://authenticator:postgres@127.0.0.1:${DB_PORT}/postgres`, ); @@ -280,6 +197,7 @@ describe("makePostgrestService", () => { it("creates a docker definition with caller-supplied topology and derived identity", () => { const dependencies = [{ service: "postgres", condition: "healthy" }] as const; const def = makePostgrestServiceDocker({ + runtime: "docker", image: dockerImageForService("postgrest", DEFAULT_VERSIONS.postgrest), identity: EPHEMERAL_IDENTITY, dbHost: "host.docker.internal", @@ -324,7 +242,7 @@ describe("makeAuthServiceNative", () => { }); expect(def.name).toBe("auth"); - expect(def.command).toBe(`${AUTH_BIN_PATH}/auth`); + expect(def.command).toBe(`${AUTH_BIN_PATH}/bin/auth`); expect(def.env?.GOTRUE_DB_DATABASE_URL).toContain(`127.0.0.1:${DB_PORT}`); expect(def.env?.GOTRUE_SITE_URL).toBe("http://localhost:3000"); expect(def.env?.GOTRUE_JWT_SECRET).toBe(JWT_SECRET); @@ -343,6 +261,7 @@ describe("makeAuthServiceNative", () => { describe("makeAuthServiceDocker", () => { it("creates a docker-based auth ServiceDef", () => { const def = makeAuthServiceDocker({ + runtime: "docker", image: dockerImageForService("auth", DEFAULT_VERSIONS.auth), dbPort: DB_PORT, authPort: 9999, @@ -383,6 +302,7 @@ describe("makeEdgeRuntimeServiceDocker", () => { try { const def = makeEdgeRuntimeServiceDocker({ + runtime: "docker", image: dockerImageForService("edge-runtime", DEFAULT_VERSIONS["edge-runtime"]), identity: EPHEMERAL_IDENTITY, runtimeRoot: tempDir, @@ -570,6 +490,7 @@ describe("docker-backed auxiliary services", () => { it("defines realtime command, topology, environment, and readiness locally", () => { const dependencies = [{ service: "postgres", condition: "healthy" }] as const; const def = makeRealtimeServiceDocker({ + runtime: "docker", image: dockerImageForService("realtime", DEFAULT_VERSIONS.realtime), identity: EPHEMERAL_IDENTITY, port: 54330, @@ -597,6 +518,7 @@ describe("docker-backed auxiliary services", () => { it("defines storage mounts, cleanup, topology, and readiness locally", () => { const dependencies = [{ service: "postgres-init", condition: "completed" }] as const; const def = makeStorageServiceDocker({ + runtime: "docker", image: dockerImageForService("storage", DEFAULT_VERSIONS.storage), identity: EPHEMERAL_IDENTITY, port: 54331, @@ -633,6 +555,7 @@ describe("docker-backed auxiliary services", () => { it("defines postgres metadata command, topology, environment, and readiness locally", () => { const dependencies = [{ service: "postgres", condition: "healthy" }] as const; const def = makePgmetaServiceDocker({ + runtime: "docker", image: dockerImageForService("pgmeta", DEFAULT_VERSIONS.pgmeta), identity: EPHEMERAL_IDENTITY, port: 54336, @@ -653,6 +576,7 @@ describe("docker-backed auxiliary services", () => { it("uses a host HTTP readiness probe for mailpit", () => { const def = makeMailpitServiceDocker({ + runtime: "docker", image: dockerImageForService("mailpit", DEFAULT_VERSIONS.mailpit), identity: EPHEMERAL_IDENTITY, webPort: 54323, @@ -673,6 +597,7 @@ describe("docker-backed auxiliary services", () => { it("uses a host HTTP health probe for imgproxy", () => { const def = makeImgproxyServiceDocker({ + runtime: "docker", image: dockerImageForService("imgproxy", DEFAULT_VERSIONS.imgproxy), identity: EPHEMERAL_IDENTITY, port: 54326, @@ -693,6 +618,7 @@ describe("docker-backed auxiliary services", () => { it("uses docker exec for vector health because its admin port is not published", () => { const def = makeVectorServiceDocker({ + runtime: "docker", image: dockerImageForService("vector", DEFAULT_VERSIONS.vector), identity: EPHEMERAL_IDENTITY, serviceHost: "127.0.0.1", @@ -717,6 +643,7 @@ describe("docker-backed auxiliary services", () => { it("binds analytics on all interfaces so published ports and proxy health checks work", () => { const def = makeAnalyticsServiceDocker({ + runtime: "docker", image: dockerImageForService("analytics", DEFAULT_VERSIONS.analytics), identity: EPHEMERAL_IDENTITY, hostPort: 54328, @@ -741,13 +668,11 @@ describe("docker-backed auxiliary services", () => { expect(args).toContain("PHX_HTTP_PORT=4000"); expect(args).toContain("54328:4000"); expect(args).toContain("LOGFLARE_NODE_HOST=0.0.0.0"); - expect(args.at(-1)).toBe( - `cat <<'EOF' > /tmp/run.sh && sh /tmp/run.sh\n./logflare eval Logflare.Release.migrate &&\n./logflare start --sname logflare\nEOF\n`, - ); }); it("keeps analytics on its container port when Linux uses bridge networking", () => { const def = makeAnalyticsServiceDocker({ + runtime: "docker", image: dockerImageForService("analytics", DEFAULT_VERSIONS.analytics), identity: EPHEMERAL_IDENTITY, hostPort: 54328, @@ -768,6 +693,7 @@ describe("docker-backed auxiliary services", () => { it("keeps pooler container ports fixed and maps only the selected proxy port outward", () => { const def = makePoolerServiceDocker({ + runtime: "docker", image: dockerImageForService("pooler", DEFAULT_VERSIONS.pooler), identity: EPHEMERAL_IDENTITY, hostAdminPort: 54329, diff --git a/packages/stack/src/services/storage.ts b/packages/stack/src/services/storage.ts index b0da6a5997..e739aa962e 100644 --- a/packages/stack/src/services/storage.ts +++ b/packages/stack/src/services/storage.ts @@ -2,10 +2,14 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; import { removePathOnOrphanCleanup } from "./docker-cleanup.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerStorageOptions { +interface DockerStorageOptions extends ContainerRuntimeOptions { readonly image: string; readonly port: number; readonly identity: StackIdentity; @@ -46,6 +50,7 @@ const storageHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ export const makeStorageServiceDocker = (opts: DockerStorageOptions): ServiceDef => dockerRunService({ + runtime: opts.runtime, name: "storage", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/studio.ts b/packages/stack/src/services/studio.ts index c1223a4882..bb5f1009d5 100644 --- a/packages/stack/src/services/studio.ts +++ b/packages/stack/src/services/studio.ts @@ -1,10 +1,14 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + type ContainerRuntimeOptions, + type ServiceDependency, +} from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerStudioOptions { +interface DockerStudioOptions extends ContainerRuntimeOptions { readonly image: string; readonly identity: StackIdentity; readonly port: number; @@ -37,6 +41,7 @@ const studioHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ export const makeStudioServiceDocker = (opts: DockerStudioOptions): ServiceDef => dockerRunService({ + runtime: opts.runtime, name: "studio", identity: opts.identity, image: opts.image, diff --git a/packages/stack/src/services/vector.ts b/packages/stack/src/services/vector.ts index b0c5841d5c..de4fc9bf93 100644 --- a/packages/stack/src/services/vector.ts +++ b/packages/stack/src/services/vector.ts @@ -1,14 +1,16 @@ -import { existsSync } from "node:fs"; +import { accessSync, constants } from "node:fs"; import { dockerNetworkArgs } from "../Platform.ts"; +import type { ContainerRuntime } from "../ContainerRuntime.ts"; import { dockerContainerName, type StackIdentity } from "../StackIdentity.ts"; import { dockerExecHealthCheck, dockerRunService, + type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerVectorOptions { +interface DockerVectorOptions extends ContainerRuntimeOptions { readonly image: string; readonly identity: StackIdentity; readonly serviceHost: string; @@ -18,19 +20,24 @@ interface DockerVectorOptions { readonly dependencies: ReadonlyArray; } -const VECTOR_CONFIG = (host: string, port: number, apiKey: string) => `api: +const vectorConfig = ( + host: string, + port: number, + apiKey: string, + logSource: "docker_logs" | "internal_logs", +) => `api: enabled: true address: 0.0.0.0:9001 sources: - docker: - type: docker_logs + runtime: + type: ${logSource} sinks: logflare: type: http inputs: - - docker + - runtime encoding: codec: json method: post @@ -41,31 +48,76 @@ sinks: uri: "http://${host}:${port}/api/logs?source_name=docker.logs.local" `; +const canAccessSocket = (socket: string): boolean => { + try { + accessSync(socket, constants.R_OK | constants.W_OK); + return true; + } catch { + return false; + } +}; + +const unixSocketFromEnv = (value: string | undefined): string | undefined => { + if (value === undefined || !value.startsWith("unix://")) return undefined; + const socket = value.slice("unix://".length); + return socket.length > 0 && canAccessSocket(socket) ? socket : undefined; +}; + +const podmanSocketCandidates = (): ReadonlyArray => { + const candidates: Array = []; + const runtimeDir = process.env.XDG_RUNTIME_DIR; + if (runtimeDir !== undefined && runtimeDir.length > 0) { + candidates.push(`${runtimeDir}/podman/podman.sock`); + } + const uid = process.getuid?.(); + if (uid !== undefined) candidates.push(`/run/user/${uid}/podman/podman.sock`); + candidates.push("/run/podman/podman.sock"); + return candidates; +}; + +const resolveVectorDockerSocket = (runtime: ContainerRuntime): string | undefined => { + if (runtime === "podman") { + const explicitPodmanSocket = unixSocketFromEnv(process.env.CONTAINER_HOST); + if (explicitPodmanSocket !== undefined) return explicitPodmanSocket; + const explicitDockerSocket = unixSocketFromEnv(process.env.DOCKER_HOST); + if (explicitDockerSocket !== undefined) return explicitDockerSocket; + return podmanSocketCandidates().find(canAccessSocket); + } + + const explicitDockerSocket = unixSocketFromEnv(process.env.DOCKER_HOST); + if (explicitDockerSocket !== undefined) return explicitDockerSocket; + return canAccessSocket("/var/run/docker.sock") ? "/var/run/docker.sock" : undefined; +}; + export const makeVectorServiceDocker = (opts: DockerVectorOptions) => { const containerName = dockerContainerName("vector", opts.identity.key); - const dockerSocket = process.env.DOCKER_HOST?.startsWith("unix://") - ? process.env.DOCKER_HOST.slice("unix://".length) - : "/var/run/docker.sock"; - const volumes = existsSync(dockerSocket) ? [`${dockerSocket}:/var/run/docker.sock:ro`] : []; + const socketPath = resolveVectorDockerSocket(opts.runtime); + const volumes = socketPath === undefined ? [] : [`${socketPath}:/var/run/docker.sock:ro`]; return dockerRunService({ + runtime: opts.runtime, name: "vector", identity: opts.identity, image: opts.image, networkArgs: dockerNetworkArgs(opts.platformOs, []), volumes, - env: { - DOCKER_HOST: "unix:///var/run/docker.sock", - }, + securityOptions: opts.runtime === "podman" && socketPath !== undefined ? ["label=disable"] : [], + env: socketPath === undefined ? {} : { DOCKER_HOST: "unix:///var/run/docker.sock" }, entrypoint: "sh", cmd: [ "-c", `cat <<'EOF' > /etc/vector/vector.yaml && vector --config /etc/vector/vector.yaml -${VECTOR_CONFIG(opts.serviceHost, opts.analyticsPort, opts.analyticsApiKey)}EOF +${vectorConfig( + opts.serviceHost, + opts.analyticsPort, + opts.analyticsApiKey, + socketPath === undefined ? "internal_logs" : "docker_logs", +)}EOF `, ], dependencies: opts.dependencies, healthCheck: dockerExecHealthCheck( + opts.runtime, containerName, "sh", ["-ec", "wget -q -O /dev/null http://127.0.0.1:9001/health"], diff --git a/packages/stack/src/services/vector.unit.test.ts b/packages/stack/src/services/vector.unit.test.ts new file mode 100644 index 0000000000..dd6941a03c --- /dev/null +++ b/packages/stack/src/services/vector.unit.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { stackIdentity } from "../StackIdentity.ts"; +import { DEFAULT_VERSIONS, dockerImageForService } from "../versions.ts"; +import { makeVectorServiceDocker } from "./vector.ts"; + +const existingPaths = vi.hoisted(() => new Set()); +const accessiblePaths = vi.hoisted(() => new Set()); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: (path: Parameters[0]) => existingPaths.has(String(path)), + accessSync: (path: Parameters[0]) => { + const socket = String(path); + if (!existingPaths.has(socket) || !accessiblePaths.has(socket)) { + throw new Error("socket is not accessible"); + } + }, + }; +}); + +const identity = stackIdentity({ apiPort: 54321 }); + +const makeVector = (runtime: "docker" | "podman") => + makeVectorServiceDocker({ + runtime, + image: dockerImageForService("vector", DEFAULT_VERSIONS.vector), + identity, + serviceHost: "127.0.0.1", + analyticsPort: 54327, + analyticsApiKey: "test-api-key", + platformOs: "linux", + dependencies: [], + }); + +describe("makeVectorServiceDocker log source", () => { + beforeEach(() => { + existingPaths.clear(); + accessiblePaths.clear(); + vi.stubEnv("CONTAINER_HOST", ""); + vi.stubEnv("DOCKER_HOST", ""); + vi.stubEnv("XDG_RUNTIME_DIR", ""); + }); + + afterEach(() => { + existingPaths.clear(); + accessiblePaths.clear(); + vi.unstubAllEnvs(); + }); + + it("uses internal_logs when Podman cannot find its own socket", () => { + existingPaths.add("/var/run/docker.sock"); + accessiblePaths.add("/var/run/docker.sock"); + + const def = makeVector("podman"); + const args = def.args ?? []; + + expect(args.join("\n")).toContain("type: internal_logs"); + expect(args).not.toContain("/var/run/docker.sock:/var/run/docker.sock:ro"); + expect(args).not.toContain("DOCKER_HOST=unix:///var/run/docker.sock"); + expect(args).not.toContain("--security-opt"); + }); + + it("connects Podman Vector to an available Podman socket", () => { + existingPaths.add("/run/podman/podman.sock"); + accessiblePaths.add("/run/podman/podman.sock"); + + const def = makeVector("podman"); + const args = def.args ?? []; + + expect(args.join("\n")).toContain("type: docker_logs"); + expect(args).toContain("/run/podman/podman.sock:/var/run/docker.sock:ro"); + expect(args).toContain("DOCKER_HOST=unix:///var/run/docker.sock"); + expect(args).toContain("--security-opt"); + expect(args).toContain("label=disable"); + }); + + it("uses internal_logs when Docker cannot find its socket", () => { + const def = makeVector("docker"); + const args = def.args ?? []; + + expect(args.join("\n")).toContain("type: internal_logs"); + expect(args).not.toContain("/var/run/docker.sock:/var/run/docker.sock:ro"); + expect(args).not.toContain("DOCKER_HOST=unix:///var/run/docker.sock"); + expect(args).not.toContain("--security-opt"); + }); + + it("uses internal_logs when the Podman socket is not readable and writable", () => { + existingPaths.add("/run/podman/podman.sock"); + + const def = makeVector("podman"); + const args = def.args ?? []; + + expect(args.join("\n")).toContain("type: internal_logs"); + expect(args).not.toContain("/run/podman/podman.sock:/var/run/docker.sock:ro"); + expect(args).not.toContain("DOCKER_HOST=unix:///var/run/docker.sock"); + expect(args).not.toContain("--security-opt"); + }); + + it("honors an explicit Docker socket for Podman Vector", () => { + existingPaths.add("/var/run/docker.sock"); + accessiblePaths.add("/var/run/docker.sock"); + vi.stubEnv("DOCKER_HOST", "unix:///var/run/docker.sock"); + + const def = makeVector("podman"); + const args = def.args ?? []; + + expect(args.join("\n")).toContain("type: docker_logs"); + expect(args).toContain("/var/run/docker.sock:/var/run/docker.sock:ro"); + expect(args).toContain("DOCKER_HOST=unix:///var/run/docker.sock"); + expect(args).toContain("--security-opt"); + expect(args).toContain("label=disable"); + }); +}); diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts index e8b0e28cd7..509296d02b 100644 --- a/packages/stack/src/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor.integration.test.ts @@ -342,7 +342,6 @@ const remoteInfo = (endpoint: ControlEndpoint): Promise<{ readonly url: string } const updateLaunch = async ( endpoint: ControlEndpoint, launch: { - readonly mode: "native" | "auto" | "docker"; readonly versions: Record; }, ): Promise => { @@ -497,8 +496,12 @@ const readStackDocument = (roots: { | { readonly id: string; readonly lifecycle: string; - readonly ports: ReadonlyArray<{ port: number }>; - readonly launch?: { readonly mode: string; readonly versions: Record }; + readonly ports: ReadonlyArray<{ key: string; port: number }>; + readonly launch?: { + readonly mode: string; + readonly containerRuntime?: string; + readonly versions: Record; + }; } | undefined => { const stacksRoot = join(roots.stateRoot, "stacks"); @@ -509,7 +512,7 @@ const readStackDocument = (roots: { return JSON.parse(readFileSync(path, "utf8")) as { readonly id: string; readonly lifecycle: string; - readonly ports: ReadonlyArray<{ port: number }>; + readonly ports: ReadonlyArray<{ key: string; port: number }>; }; } return undefined; @@ -673,6 +676,76 @@ describe("detached supervisor child journeys", () => { } }); + test("starts an omitted-mode stack from one detected runtime selection", async () => { + const roots = await workspace(); + const binDir = mkdtempSync(join(tmpdir(), "sup-stack-runtime-")); + const docker = join(binDir, "docker"); + writeFileSync(docker, "#!/bin/sh\nexit 0\n"); + chmodSync(docker, 0o755); + const base = messageFor(roots); + const { mode: _mode, ...config } = base.config; + const child = spawnChild( + messageFor(roots, { + config: { ...config, edgeRuntime: {} }, + }), + { environment: { PATH: `${binDir}:${process.env["PATH"] ?? ""}` } }, + ); + try { + const started = await child.started; + const document = readStackDocument(roots); + expect(document?.launch).toMatchObject({ + mode: "docker", + containerRuntime: "docker", + }); + expect(document?.ports.map(({ key }) => key)).toContain("edge_runtime.inspector_port"); + await remoteStop(started.endpoint); + await waitForExit(child.child); + } finally { + if (child.child.exitCode === null) await kill(child.child); + cleanupRoots(roots); + rmSync(binDir, { recursive: true, force: true }); + } + }); + + test("reuses the persisted runtime instead of selecting a different one on restart", async () => { + const roots = await workspace(); + const binDir = mkdtempSync(join(tmpdir(), "sup-stack-sticky-runtime-")); + const docker = join(binDir, "docker"); + const podman = join(binDir, "podman"); + writeFileSync(docker, "#!/bin/sh\nexit 0\n"); + writeFileSync(podman, "#!/bin/sh\nexit 1\n"); + chmodSync(docker, 0o755); + chmodSync(podman, 0o755); + const base = messageFor(roots); + const { mode: _mode, ...config } = base.config; + const input = messageFor(roots, { config }); + const environment = { PATH: `${binDir}:${process.env["PATH"] ?? ""}` }; + const initial = spawnChild(input, { environment }); + let restarted: ChildHandle | undefined; + try { + const started = await initial.started; + await remoteStop(started.endpoint); + await waitForExit(initial.child); + + writeFileSync(docker, "#!/bin/sh\nexit 1\n"); + writeFileSync(podman, "#!/bin/sh\nexit 0\n"); + restarted = spawnChild(input, { environment }); + + await expect(restarted.started).rejects.toThrow( + "Docker mode requires a usable docker runtime", + ); + expect(readStackDocument(roots)?.launch).toMatchObject({ + mode: "docker", + containerRuntime: "docker", + }); + } finally { + if (initial.child.exitCode === null) await kill(initial.child); + if (restarted?.child.exitCode === null) await kill(restarted.child); + cleanupRoots(roots); + rmSync(binDir, { recursive: true, force: true }); + } + }); + test("publishes stopping before a slow owner shutdown can finish", async () => { const roots = await workspace(); const child = spawnChild(messageFor(roots), { testMode: "hold-stop" }); @@ -1150,9 +1223,9 @@ describe("detached supervisor child journeys", () => { const attached = await later.started; expect(attached.attached).toBe(true); expect(await remoteInfo(attached.endpoint)).toMatchObject({ url: expect.any(String) }); - await updateLaunch(attached.endpoint, { mode: "auto", versions: { postgres: "17.6.1" } }); + await updateLaunch(attached.endpoint, { versions: { postgres: "17.6.1" } }); expect(readStackDocument(roots)?.launch).toEqual({ - mode: "auto", + mode: "native", versions: { postgres: "17.6.1" }, }); await remoteStop(attached.endpoint); diff --git a/packages/stack/src/supervisor.ts b/packages/stack/src/supervisor.ts index 478b489ac9..e64f116867 100644 --- a/packages/stack/src/supervisor.ts +++ b/packages/stack/src/supervisor.ts @@ -12,6 +12,12 @@ import { Schema, } from "effect"; import { HttpServer } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { + selectStackRuntime, + validateStackRuntime, + type StackRuntimeSelection, +} from "./ContainerRuntime.ts"; import type { PlatformFactory } from "./createStack.ts"; import { DaemonServer } from "./DaemonServer.ts"; import { Stack } from "./Stack.ts"; @@ -26,12 +32,17 @@ import { type ControlTransport, } from "./managed/control.ts"; import { ManagedStackManager, type ManagedStackStartResult } from "./managed/manager.ts"; -import { managedStackLaunchSchema } from "./managed/document.ts"; +import { + managedStackLaunchInputSchema, + type ManagedStackLaunch, + type ManagedStackLaunchInput, +} from "./managed/document.ts"; import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; import { gitConfigStoreLayer } from "./managed/git.ts"; import { validateManagedStackName, type ManagedPortIntentDocument } from "./managed/model.ts"; import { managedStackPaths } from "./managed/paths.ts"; -import { PORT_FIELDS, type PortField, type PortSet } from "./PortCatalog.ts"; +import { PORT_CATALOG, PORT_FIELDS, type PortField, type PortSet } from "./PortCatalog.ts"; +import { portFieldsForConfigInput } from "./ServicePorts.ts"; import { SERVICE_NAMES } from "./ServiceCatalog.ts"; import { dockerContainerName } from "./StackIdentity.ts"; import type { PortAllocationError, PortLease } from "./PortAllocator.ts"; @@ -51,7 +62,7 @@ export interface SupervisorStartMessage { readonly stateRoot: string; readonly config: Readonly>; readonly portIntents: ManagedPortIntentDocument; - readonly launch?: import("./managed/document.ts").ManagedStackDocument["launch"]; + readonly launch?: ManagedStackLaunchInput; } export interface SupervisorStartedMessage { @@ -73,7 +84,7 @@ export interface ManagedDaemonStartInput { readonly stateRoot: string; readonly config: Readonly>; readonly portIntents: ManagedPortIntentDocument; - readonly launch?: import("./managed/document.ts").ManagedStackDocument["launch"]; + readonly launch?: ManagedStackLaunchInput; } const supervisorPortIntentSchema = Schema.Struct({ @@ -90,7 +101,7 @@ const supervisorStartMessageSchema = Schema.Struct({ stateRoot: Schema.String, config: Schema.Record(Schema.String, Schema.Unknown), portIntents: supervisorPortIntentSchema, - launch: Schema.optionalKey(managedStackLaunchSchema), + launch: Schema.optionalKey(managedStackLaunchInputSchema), }); const isRecord = (value: unknown): value is Readonly> => @@ -106,8 +117,18 @@ const decodeSupervisorStartMessage = (value: unknown): SupervisorStartMessage => return Schema.decodeUnknownSync(supervisorStartMessageSchema)(value); }; -const causeMessage = (cause: unknown): string => - cause instanceof Error ? cause.message : typeof cause === "string" ? cause : String(cause); +const causeMessage = (cause: unknown): string => { + if (cause instanceof Error && cause.message.length > 0) return cause.message; + if ( + typeof cause === "object" && + cause !== null && + "detail" in cause && + typeof cause.detail === "string" + ) { + return cause.detail; + } + return typeof cause === "string" ? cause : String(cause); +}; const toDaemonConfig = (value: Readonly>): DaemonConfigInput | undefined => typeof value.cwd === "string" ? { ...value, cwd: value.cwd } : undefined; @@ -266,7 +287,7 @@ const startDaemon = (input: { readonly platform: SupervisorPlatform; readonly scope: Scope.Scope; readonly launchUpdate?: ( - launch: NonNullable, + launch: import("./managed/document.ts").ManagedStackLaunchUpdate, ) => Effect.Effect; }): Effect.Effect< { readonly daemon: DaemonServer["Service"] }, @@ -310,6 +331,7 @@ const runManaged = ( | ControlTransport | import("effect").FileSystem.FileSystem | import("effect").Path.Path + | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > => { let owner: ControlOwnership | undefined; @@ -444,8 +466,49 @@ const runManaged = ( ); } } + const existing = yield* manager.inspectStack(stackId); + const requestedMode = + configInput.mode ?? + (input.launch !== undefined && "mode" in input.launch ? input.launch.mode : undefined); + const existingLaunch = existing?.launch; + const persistedRuntime: StackRuntimeSelection | undefined = + existingLaunch !== undefined && "mode" in existingLaunch && existingLaunch.mode === "native" + ? { mode: "native", containerRuntime: null } + : existingLaunch !== undefined && + "mode" in existingLaunch && + existingLaunch.mode === "docker" + ? { mode: "docker", containerRuntime: existingLaunch.containerRuntime } + : undefined; + if ( + persistedRuntime !== undefined && + requestedMode !== undefined && + persistedRuntime.mode !== requestedMode + ) { + return yield* Effect.fail( + new SupervisorStartError({ + message: `Stack runtime is already ${persistedRuntime.mode}; requested ${requestedMode}. Delete and recreate the stack (removing its managed data) before changing execution mode.`, + }), + ); + } + const runtime = + persistedRuntime === undefined + ? yield* selectStackRuntime(requestedMode) + : yield* validateStackRuntime(persistedRuntime); + const activeFields = portFieldsForConfigInput({ ...configInput, mode: runtime.mode }); + const activeFieldSet = new Set(activeFields); + const portIntents: ManagedPortIntentDocument = { + ...input.portIntents, + activeFields, + disabledFields: PORT_FIELDS.filter( + (field) => PORT_CATALOG[field].persistence === "sticky" && !activeFieldSet.has(field), + ), + }; + const launchInput = input.launch ?? { versions: {} }; + const launch: ManagedStackLaunch = + runtime.containerRuntime === null + ? { ...launchInput, mode: "native" } + : { ...launchInput, mode: "docker", containerRuntime: runtime.containerRuntime }; const startup = Effect.gen(function* () { - const existing = yield* manager.inspectStack(stackId); if ( existing !== undefined && (existing.lifecycle === "starting" || @@ -453,17 +516,20 @@ const runManaged = ( existing.lifecycle === "failed" || existing.lifecycle === "deleting") ) { - yield* dockerForceRemove( - SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), - ); + if (runtime.containerRuntime !== null) { + yield* dockerForceRemove( + runtime.containerRuntime, + SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), + ); + } } const started: ManagedStackStartResult = yield* manager.startStack({ workspacePath: input.workspacePath, stackName: input.stackName, - portDocument: input.portIntents, + portDocument: portIntents, ownership, lifecycle: "starting", - launch: input.launch, + launch, }); claimedStack = true; const resolved = yield* Effect.tryPromise({ @@ -476,7 +542,7 @@ const runManaged = ( runtimeRoot: managedStackPaths(input.stateRoot, started.stack.id).runtime, instanceId: started.stack.id, }, - { portAllocator: () => Effect.succeed(started.lease.ports) }, + { runtime, portAllocator: () => Effect.succeed(started.lease.ports) }, ), catch: (cause) => cause, }); @@ -568,7 +634,10 @@ export const runSupervisor = ( ): Effect.Effect< void, SupervisorStartError | unknown, - ControlTransport | import("effect").FileSystem.FileSystem | import("effect").Path.Path + | ControlTransport + | import("effect").FileSystem.FileSystem + | import("effect").Path.Path + | ChildProcessSpawner.ChildProcessSpawner > => Effect.scoped( Effect.gen(function* () { @@ -576,7 +645,7 @@ export const runSupervisor = ( const input = yield* receiveStartMessage(); yield* Effect.matchCauseEffect(runManaged(input, platform, scope), { onFailure: (cause) => - sendMessage({ type: "error", message: causeMessage(cause) }).pipe( + sendMessage({ type: "error", message: causeMessage(Cause.squash(cause)) }).pipe( Effect.andThen(Effect.failCause(cause)), ), onSuccess: Effect.succeed, diff --git a/packages/stack/src/version-plan.unit.test.ts b/packages/stack/src/version-plan.unit.test.ts index afc6e400c1..0f74bd061b 100644 --- a/packages/stack/src/version-plan.unit.test.ts +++ b/packages/stack/src/version-plan.unit.test.ts @@ -16,14 +16,14 @@ describe("planStackVersions", () => { candidateBaseline: { ...DEFAULT_VERSIONS, postgres: "17.6.1.090", - postgrest: "14.5", - auth: "2.187.0", + postgrest: "v14.5", + auth: "v2.187.0", }, pinnedBaseline: { ...DEFAULT_VERSIONS, postgres: "17.6.1.090", - postgrest: "14.5", - auth: "2.187.0", + postgrest: "v14.5", + auth: "v2.187.0", }, }); }); @@ -49,13 +49,13 @@ describe("planStackVersions", () => { runtimeVersions: { ...DEFAULT_VERSIONS, postgres: "17.4.1.045", - postgrest: "14.5", - auth: "2.170.0", + postgrest: "v14.5", + auth: "v2.170.0", storage: "1.40.0", }, activeOverrides: [ { service: "postgres", version: "17.4.1.045", source: "flag" }, - { service: "auth", version: "2.170.0", source: "flag" }, + { service: "auth", version: "v2.170.0", source: "flag" }, { service: "storage", version: "1.40.0", source: "local" }, ], }); @@ -79,15 +79,15 @@ describe("planStackVersions", () => { { service: "auth", pinnedVersion: "2.188.0-rc.15", - availableVersion: "2.188.1", + availableVersion: "v2.188.1", }, { service: "storage", pinnedVersion: "1.41.8", - availableVersion: "1.43.3", + availableVersion: "v1.43.3", }, ], - updateFingerprint: "auth:2.188.0-rc.15->2.188.1|storage:1.41.8->1.43.3", + updateFingerprint: "auth:2.188.0-rc.15->v2.188.1|storage:1.41.8->v1.43.3", }); }); }); diff --git a/packages/stack/src/versions.ts b/packages/stack/src/versions.ts index 4828de5064..2524890891 100644 --- a/packages/stack/src/versions.ts +++ b/packages/stack/src/versions.ts @@ -1,11 +1,11 @@ import { DEFAULT_VERSIONS, SERVICE_NAMES, - dockerImageCandidatesForArtifact, dockerImageForArtifact, - imageTagPrefixForService, + serviceMetadata, } from "./ServiceCatalog.ts"; import type { ServiceName } from "./ServiceName.ts"; +import { Schema } from "effect"; export { DEFAULT_VERSIONS, SERVICE_NAMES } from "./ServiceCatalog.ts"; export type { ServiceName } from "./ServiceName.ts"; @@ -30,30 +30,13 @@ export const PartialVersionManifestSchema = Schema.Struct({ export type PartialVersionManifest = Schema.Schema.Type; -export const IMAGE_TAG_PREFIX: Partial> = Object.fromEntries( - SERVICE_NAMES.flatMap((service) => { - const prefix = imageTagPrefixForService(service); - return prefix === undefined ? [] : [[service, prefix]]; - }), -); - /** * Returns the full Docker image URL for a service. - * - * Uses the same registry resolution as the Go CLI: images are pulled from - * `public.ecr.aws/supabase/` by default (faster than Docker Hub). */ export function dockerImageForService(service: ServiceName, version: string): string { return dockerImageForArtifact(service, version); } -export function dockerImageCandidatesForService( - service: ServiceName, - version: string, -): ReadonlyArray { - return dockerImageCandidatesForArtifact(service, version); -} - function assertFullVersions( versions: Partial>, ): asserts versions is Record { @@ -70,28 +53,13 @@ export function fullVersionManifest( return versions; } -/** - * Normalizes a version string for a service based on its image tag prefix. - * - * Services with a "v" prefix in IMAGE_TAG_PREFIX (e.g. postgrest, auth) store - * versions without the "v" prefix (it gets prepended at image-pull time). - * Services without a prefix entry but whose DEFAULT_VERSIONS start with "v" - * (e.g. imgproxy, mailpit) store versions with the "v" prefix. - * All other services pass through trimmed. - */ +/** Normalizes a version string to the catalog's canonical stored form. */ export function normalizeServiceVersion(service: ServiceName, version: string): string { - const trimmed = version.trim(); - const prefix = IMAGE_TAG_PREFIX[service]; - - if (prefix === "v") { - return trimmed.replace(/^v/i, ""); - } - - if (prefix === undefined && DEFAULT_VERSIONS[service].startsWith("v")) { - return /^v/i.test(trimmed) ? `v${trimmed.slice(1)}` : `v${trimmed}`; - } - - return trimmed; + const normalized = version.trim(); + const tagPrefix = serviceMetadata(service).artifact.docker.tagPrefix; + return tagPrefix !== undefined && normalized.startsWith(tagPrefix) + ? normalized.slice(tagPrefix.length) + : normalized; } export function normalizeServiceVersions( @@ -136,4 +104,3 @@ export function diffPinnedAndAvailableVersions( return [{ service, pinnedVersion, availableVersion }]; }); } -import { Schema } from "effect"; diff --git a/packages/stack/src/versions.unit.test.ts b/packages/stack/src/versions.unit.test.ts index bd5e584c3f..70edb638a6 100644 --- a/packages/stack/src/versions.unit.test.ts +++ b/packages/stack/src/versions.unit.test.ts @@ -6,7 +6,6 @@ import { import { DEFAULT_VERSIONS, diffPinnedAndAvailableVersions, - dockerImageCandidatesForService, dockerImageForService, fillServiceVersionManifest, normalizeServiceVersion, @@ -52,7 +51,7 @@ describe("syncDefaultVersionsSource", () => { 'name: "postgres",\n configKey: "example",\n defaultVersion: "17.0.0.1"', ); expect(updated).toContain( - 'name: "edge-runtime",\n configKey: "example",\n defaultVersion: "1.70.0"', + 'name: "edge-runtime",\n configKey: "example",\n defaultVersion: "v1.70.0"', ); expect(updated).toContain( 'name: "mailpit",\n configKey: "example",\n defaultVersion: "v1.2.3"', @@ -82,73 +81,72 @@ describe("dockerImageForService", () => { it("returns correct image for postgres", () => { expect(dockerImageForService("postgres", DEFAULT_VERSIONS.postgres)).toBe( - `public.ecr.aws/supabase/postgres:${DEFAULT_VERSIONS.postgres}`, + `ghcr.io/supabase/cli/postgres:${DEFAULT_VERSIONS.postgres}`, ); }); it("returns correct image for postgrest (with v prefix)", () => { expect(dockerImageForService("postgrest", DEFAULT_VERSIONS.postgrest)).toBe( - `public.ecr.aws/supabase/postgrest:v${DEFAULT_VERSIONS.postgrest}`, + `ghcr.io/supabase/cli/postgrest:${DEFAULT_VERSIONS.postgrest}`, ); }); it("returns correct image for auth (with v prefix)", () => { expect(dockerImageForService("auth", DEFAULT_VERSIONS.auth)).toBe( - `public.ecr.aws/supabase/gotrue:v${DEFAULT_VERSIONS.auth}`, + `ghcr.io/supabase/cli/auth:${DEFAULT_VERSIONS.auth}`, ); }); it("returns correct image for edge-runtime (with v prefix)", () => { expect(dockerImageForService("edge-runtime", DEFAULT_VERSIONS["edge-runtime"])).toBe( - `public.ecr.aws/supabase/edge-runtime:v${DEFAULT_VERSIONS["edge-runtime"]}`, + `ghcr.io/supabase/cli/edge-runtime:${DEFAULT_VERSIONS["edge-runtime"]}`, ); }); - it("returns ECR, Docker Hub, and GHCR candidates for Supabase-owned images", () => { - expect(dockerImageCandidatesForService("auth", DEFAULT_VERSIONS.auth)).toEqual([ - `public.ecr.aws/supabase/gotrue:v${DEFAULT_VERSIONS.auth}`, - `supabase/gotrue:v${DEFAULT_VERSIONS.auth}`, - `ghcr.io/supabase/gotrue:v${DEFAULT_VERSIONS.auth}`, - ]); - }); - - it("does not add fallback registries for third-party images", () => { - expect(dockerImageCandidatesForService("imgproxy", DEFAULT_VERSIONS.imgproxy)).toEqual([ - `darthsim/imgproxy:${DEFAULT_VERSIONS.imgproxy}`, - ]); + it("uses canonical GHCR for every service", () => { + expect(dockerImageForService("imgproxy", DEFAULT_VERSIONS.imgproxy)).toBe( + `ghcr.io/supabase/cli/imgproxy:${DEFAULT_VERSIONS.imgproxy}`, + ); }); it("keeps non-managed services Docker-only", () => { expect(SERVICE_CATALOG.imgproxy).toMatchObject({ runtimeSupport: "docker-only", - artifact: { docker: { ownership: "upstream", repository: "darthsim/imgproxy" } }, + artifact: { docker: { repository: "imgproxy" } }, }); expect(SERVICE_CATALOG.mailpit).toMatchObject({ runtimeSupport: "docker-only", - artifact: { docker: { ownership: "upstream", repository: "axllent/mailpit" } }, + artifact: { docker: { repository: "mailpit" } }, }); expect(SERVICE_CATALOG.vector).toMatchObject({ runtimeSupport: "docker-only", - artifact: { docker: { ownership: "upstream", repository: "timberio/vector" } }, + artifact: { docker: { repository: "vector" } }, }); }); }); describe("normalizeServiceVersion", () => { - it("strips v prefix for services with IMAGE_TAG_PREFIX 'v'", () => { - expect(normalizeServiceVersion("postgrest", "v14.5")).toBe("14.5"); - expect(normalizeServiceVersion("auth", "v2.188.0")).toBe("2.188.0"); - expect(normalizeServiceVersion("edge-runtime", "v1.73.0")).toBe("1.73.0"); + it("preserves frozen leading v tags", () => { + expect(normalizeServiceVersion("postgrest", "v14.5")).toBe("v14.5"); + expect(normalizeServiceVersion("auth", "v2.188.0")).toBe("v2.188.0"); + expect(normalizeServiceVersion("edge-runtime", "v1.73.0")).toBe("v1.73.0"); }); - it("ensures v prefix for services whose defaults start with v", () => { - expect(normalizeServiceVersion("mailpit", "1.30.2")).toBe("v1.30.2"); - expect(normalizeServiceVersion("imgproxy", "3.8.0")).toBe("v3.8.0"); + it("preserves explicit release tags", () => { + expect(normalizeServiceVersion("mailpit", "1.30.2")).toBe("1.30.2"); + expect(normalizeServiceVersion("imgproxy", "3.8.0")).toBe("3.8.0"); }); it("passes through other services unchanged", () => { expect(normalizeServiceVersion("postgres", "17.6.1.090")).toBe("17.6.1.090"); }); + + it("normalizes a prefixed pgmeta override to its catalog tag", () => { + expect(normalizeServiceVersion("pgmeta", "v0.98.0")).toBe("0.98.0"); + expect(dockerImageForService("pgmeta", normalizeServiceVersion("pgmeta", "v0.98.0"))).toBe( + "ghcr.io/supabase/cli/pgmeta:v0.98.0", + ); + }); }); describe("fillServiceVersionManifest", () => { diff --git a/packages/stack/tests/createStack-docker.e2e.test.ts b/packages/stack/tests/createStack-docker.e2e.test.ts index 8f81ad820e..778bb1b75e 100644 --- a/packages/stack/tests/createStack-docker.e2e.test.ts +++ b/packages/stack/tests/createStack-docker.e2e.test.ts @@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { activationTimeoutSecondsForService } from "../src/ServiceActivation.ts"; import { createStack, type StackHandle } from "../src/node.ts"; import { dependencyTimeoutSecondsForServices } from "../src/services/health-budgets.ts"; +import { DEFAULT_VERSIONS } from "../src/versions.ts"; import { setupTestTable } from "./helpers/e2e.ts"; const STACK_DOCKER_E2E_TEST_TIMEOUT_MS = 180_000; @@ -37,7 +38,6 @@ dockerDescribe("createStack e2e (docker mode)", () => { stack = await createStack({ mode: "docker", - startupMode: "lazy", jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", postgres: { dataDir }, analytics: {}, @@ -51,7 +51,15 @@ dockerDescribe("createStack e2e (docker mode)", () => { } const dbPort = parseInt(new URL(stack.dbUrl).port); - await setupTestTable(dbPort); + try { + await setupTestTable(dbPort); + } catch (error) { + const status = await stack.getStatus(); + const logs = await stack.logHistory("postgres"); + throw new Error( + `setupTestTable failed: ${String(error)}\nstatus=${JSON.stringify(status)}\nlogs=${JSON.stringify(logs)}`, + ); + } apiPort = new URL(stack.url).port; supabase = createClient(stack.url, stack.publishableKey); @@ -78,9 +86,11 @@ dockerDescribe("createStack e2e (docker mode)", () => { await Promise.all([stack.startService("postgrest"), stack.startService("auth")]); const runningImages = execSync("docker ps --format '{{.Image}}'").toString(); - expect(runningImages).toContain("supabase/postgrest"); - expect(runningImages).toContain("supabase/postgres"); - expect(runningImages).toContain("supabase/gotrue"); + expect(runningImages).toContain( + `ghcr.io/supabase/cli/postgrest:${DEFAULT_VERSIONS.postgrest}`, + ); + expect(runningImages).toContain(`ghcr.io/supabase/cli/postgres:${DEFAULT_VERSIONS.postgres}`); + expect(runningImages).toContain(`ghcr.io/supabase/cli/auth:${DEFAULT_VERSIONS.auth}`); const [proxyRes, authRes] = await Promise.all([ fetch(`${stack.url}/health`), @@ -104,7 +114,9 @@ dockerDescribe("createStack e2e (docker mode)", () => { const runningImages = execSync("docker ps --format '{{.Image}}'").toString(); const states = await stack.getStatus(); - expect(runningImages).toContain("supabase/edge-runtime"); + expect(runningImages).toContain( + `ghcr.io/supabase/cli/edge-runtime:${DEFAULT_VERSIONS["edge-runtime"]}`, + ); expect(states).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "edge-runtime", status: "Healthy" }), @@ -133,7 +145,9 @@ dockerDescribe("createStack e2e (docker mode)", () => { stack.getStatus(), ]); - expect(runningImages).toContain("supabase/logflare"); + expect(runningImages).toContain( + `ghcr.io/supabase/cli/analytics:${DEFAULT_VERSIONS.analytics}`, + ); expect(states).toEqual( expect.arrayContaining([expect.objectContaining({ name: "analytics", status: "Healthy" })]), ); diff --git a/packages/stack/tests/createStack-native.e2e.test.ts b/packages/stack/tests/createStack-native.e2e.test.ts new file mode 100644 index 0000000000..34a145b271 --- /dev/null +++ b/packages/stack/tests/createStack-native.e2e.test.ts @@ -0,0 +1,45 @@ +import { createClient } from "@supabase/supabase-js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { createStack, type StackHandle } from "../src/node.ts"; +import { setupTestTable } from "./helpers/e2e.ts"; + +describe("native PostgREST tracer bullet", () => { + let stack: StackHandle; + let dataDir: string; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), "supabase-native-postgrest-e2e-")); + stack = await createStack({ + mode: "native", + functions: false, + edgeRuntime: false, + auth: false, + postgres: { dataDir }, + }); + await stack.start(); + await setupTestTable(parseInt(new URL(stack.dbUrl).port)); + }, 45_000); + + afterAll(async () => { + await stack?.dispose(); + rmSync(dataDir, { recursive: true, force: true }); + }, 30_000); + + test("serves a CRUD request through the native PostgREST resource", async () => { + const client = createClient(stack.url, stack.publishableKey); + const inserted = await client + .from("todos") + .insert({ title: "native tracer bullet" }) + .select() + .single(); + + expect(inserted.error).toBeNull(); + expect(inserted.data).toEqual(expect.objectContaining({ title: "native tracer bullet" })); + + const deleted = await client.from("todos").delete().eq("title", "native tracer bullet"); + expect(deleted.error).toBeNull(); + }, 30_000); +}); diff --git a/packages/stack/tests/createStack.e2e.test.ts b/packages/stack/tests/createStack.e2e.test.ts index 6511c750ad..3afdaa7a57 100644 --- a/packages/stack/tests/createStack.e2e.test.ts +++ b/packages/stack/tests/createStack.e2e.test.ts @@ -3,10 +3,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { activationTimeoutSecondsForService } from "../src/ServiceActivation.ts"; import { createStack, type ResolvedFunctionsBundle, type StackHandle } from "../src/node.ts"; import { fetchFunctionWhenReady, setupTestTable } from "./helpers/e2e.ts"; -const STACK_E2E_TEST_TIMEOUT_MS = 5_000; +const AUTH_COLD_START_TEST_TIMEOUT_MS = activationTimeoutSecondsForService("auth") * 1000; describe("createStack e2e", () => { let stack: StackHandle; @@ -47,24 +48,6 @@ describe("createStack e2e", () => { } catch {} }, 30_000); - test( - "serves health endpoints through the local gateway", - { timeout: STACK_E2E_TEST_TIMEOUT_MS }, - async () => { - const [proxyRes, authRes] = await Promise.all([ - fetch(`${stack.url}/health`), - fetch(`${stack.url}/auth/v1/health`), - ]); - - expect(proxyRes.status).toBe(200); - expect(await proxyRes.text()).toBe("OK"); - expect(authRes.status).toBe(200); - expect(await authRes.json()).toEqual( - expect.objectContaining({ description: expect.any(String) }), - ); - }, - ); - test( "serves detected Edge Functions through the local gateway", { timeout: 30_000 }, @@ -72,10 +55,8 @@ describe("createStack e2e", () => { // "Healthy" only means the edge-runtime control plane answered its health // probe; the first request to a function still lazily cold-boots a user // worker, so wait for the function to actually become servable. - const [states, functionsRes] = await Promise.all([ - stack.getStatus(), - fetchFunctionWhenReady(`${stack.url}/functions/v1/hello`), - ]); + const functionsRes = await fetchFunctionWhenReady(`${stack.url}/functions/v1/hello`); + const states = await stack.getStatus(); expect(states).toEqual( expect.arrayContaining([ @@ -99,7 +80,7 @@ describe("createStack e2e", () => { test( "supports the auth signup and session golden path", - { timeout: STACK_E2E_TEST_TIMEOUT_MS }, + { timeout: AUTH_COLD_START_TEST_TIMEOUT_MS }, async () => { const testEmail = `test-${Date.now()}@example.com`; const testPassword = "test-password-123"; @@ -126,38 +107,34 @@ describe("createStack e2e", () => { }, ); - test( - "supports a full PostgREST CRUD golden path", - { timeout: STACK_E2E_TEST_TIMEOUT_MS }, - async () => { - const seeded = await supabase.from("todos").select("*").order("id"); - expect(seeded.error).toBeNull(); - expect(seeded.data).toHaveLength(2); - - const inserted = await supabase - .from("todos") - .insert({ title: "E2E test todo" }) - .select() - .single(); - expect(inserted.error).toBeNull(); - expect(inserted.data?.title).toBe("E2E test todo"); - - const updated = await supabase - .from("todos") - .update({ completed: true }) - .eq("title", "E2E test todo") - .select() - .single(); - expect(updated.error).toBeNull(); - expect(updated.data?.completed).toBe(true); - - const deleted = await supabase.from("todos").delete().eq("title", "E2E test todo"); - expect(deleted.error).toBeNull(); - - const remaining = await supabase.from("todos").select("*").eq("title", "E2E test todo"); - expect(remaining.data).toHaveLength(0); - }, - ); + test("supports a full PostgREST CRUD golden path", { timeout: 30_000 }, async () => { + const seeded = await supabase.from("todos").select("*").order("id"); + expect(seeded.error).toBeNull(); + expect(seeded.data).toHaveLength(2); + + const inserted = await supabase + .from("todos") + .insert({ title: "E2E test todo" }) + .select() + .single(); + expect(inserted.error).toBeNull(); + expect(inserted.data?.title).toBe("E2E test todo"); + + const updated = await supabase + .from("todos") + .update({ completed: true }) + .eq("title", "E2E test todo") + .select() + .single(); + expect(updated.error).toBeNull(); + expect(updated.data?.completed).toBe(true); + + const deleted = await supabase.from("todos").delete().eq("title", "E2E test todo"); + expect(deleted.error).toBeNull(); + + const remaining = await supabase.from("todos").select("*").eq("title", "E2E test todo"); + expect(remaining.data).toHaveLength(0); + }); }); function writeFunction(projectDir: string, slug: string, body: string) { diff --git a/packages/stack/tests/global-setup.ts b/packages/stack/tests/global-setup.ts index f396e68f24..108d681c57 100644 --- a/packages/stack/tests/global-setup.ts +++ b/packages/stack/tests/global-setup.ts @@ -1,5 +1,5 @@ import { warmStackE2eDependencies } from "./helpers/warmup.ts"; export async function setup(): Promise { - await warmStackE2eDependencies(); + await warmStackE2eDependencies({ failOnError: true }); } diff --git a/packages/stack/tests/helpers/e2e.ts b/packages/stack/tests/helpers/e2e.ts index d22aefbb48..c62180b672 100644 --- a/packages/stack/tests/helpers/e2e.ts +++ b/packages/stack/tests/helpers/e2e.ts @@ -50,32 +50,33 @@ export async function fetchFunctionWhenReady( */ export async function setupTestTable(dbPort: number): Promise { const sql = new Bun.SQL(`postgresql://supabase_admin:postgres@127.0.0.1:${dbPort}/postgres`); + try { + await sql.unsafe(` + CREATE TABLE IF NOT EXISTS public.todos ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT false + ); - await sql.unsafe(` - CREATE TABLE IF NOT EXISTS public.todos ( - id SERIAL PRIMARY KEY, - title TEXT NOT NULL, - completed BOOLEAN NOT NULL DEFAULT false - ); - - ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY; - - DO $$ BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename = 'todos' AND policyname = 'allow_all') THEN - CREATE POLICY allow_all ON public.todos FOR ALL USING (true) WITH CHECK (true); - END IF; - END $$; + ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY; - GRANT ALL ON public.todos TO anon, authenticated, service_role; - GRANT USAGE, SELECT ON SEQUENCE public.todos_id_seq TO anon, authenticated, service_role; + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename = 'todos' AND policyname = 'allow_all') THEN + CREATE POLICY allow_all ON public.todos FOR ALL USING (true) WITH CHECK (true); + END IF; + END $$; - INSERT INTO public.todos (title, completed) VALUES - ('Learn Supabase', true), - ('Build an app', false); - `); + GRANT ALL ON public.todos TO anon, authenticated, service_role; + GRANT USAGE, SELECT ON SEQUENCE public.todos_id_seq TO anon, authenticated, service_role; - // PostgREST caches schema metadata, so tell it to reload after creating test tables. - await sql.unsafe(`NOTIFY pgrst, 'reload schema';`); + INSERT INTO public.todos (title, completed) VALUES + ('Learn Supabase', true), + ('Build an app', false); + `); - sql.close(); + // PostgREST caches schema metadata, so tell it to reload after creating test tables. + await sql.unsafe(`NOTIFY pgrst, 'reload schema';`); + } finally { + await sql.close(); + } } diff --git a/packages/stack/tests/helpers/mocks.ts b/packages/stack/tests/helpers/mocks.ts index 6017124333..efce61a07f 100644 --- a/packages/stack/tests/helpers/mocks.ts +++ b/packages/stack/tests/helpers/mocks.ts @@ -14,6 +14,8 @@ export function mockBinaryResolver( downloadDelayMs?: number; downloadDelaysMs?: Partial>; failServices?: string[]; + failOnceServices?: string[]; + beforeResolve?: (spec: BinarySpec) => Effect.Effect; } = {}, ) { const resolved: Array<{ service: string; version: string }> = []; @@ -23,9 +25,10 @@ export function mockBinaryResolver( auth: `/cache/auth/${DEFAULT_VERSIONS.auth}/arm64`, "edge-runtime": `/cache/edge-runtime/${DEFAULT_VERSIONS["edge-runtime"]}/aarch64-darwin`, }; + const failOnceServices = new Set(opts.failOnceServices ?? []); const resolveWithMetadata = (spec: BinarySpec, options?: ResolveBinaryOptions) => Effect.gen(function* () { - if (opts.failServices?.includes(spec.service)) { + if (opts.failServices?.includes(spec.service) || failOnceServices.delete(spec.service)) { return yield* new BinaryNotFoundError({ service: spec.service, platform: "darwin-arm64", @@ -42,6 +45,7 @@ export function mockBinaryResolver( const downloaded = opts.downloadedServices?.includes(spec.service) ?? false; if (downloaded) { yield* options?.onDownloadStart ?? Effect.void; + yield* opts.beforeResolve?.(spec) ?? Effect.void; const delayMs = opts.downloadDelaysMs?.[spec.service] ?? opts.downloadDelayMs ?? 0; if (delayMs > 0) { yield* Effect.sleep(`${delayMs} millis`); @@ -52,6 +56,14 @@ export function mockBinaryResolver( return { layer: Layer.succeed(BinaryResolver, { + plan: (spec) => { + const path = binaries[spec.service]; + return path + ? Effect.succeed(path) + : Effect.fail( + new BinaryNotFoundError({ service: spec.service, platform: "darwin-arm64" }), + ); + }, resolveWithMetadata, resolve: (spec) => Effect.map(resolveWithMetadata(spec), ({ path }) => path), }), diff --git a/packages/stack/tests/helpers/warmup.ts b/packages/stack/tests/helpers/warmup.ts index 7660bdc578..9db0bf2114 100644 --- a/packages/stack/tests/helpers/warmup.ts +++ b/packages/stack/tests/helpers/warmup.ts @@ -29,18 +29,19 @@ export async function warmStackE2eDependencies( const shouldFailOnError = options.failOnError ?? false; const dockerAvailable = (options.hasDockerDaemon ?? hasDockerDaemon)(); - try { - const warmups = [prefetchDeps()]; - if (dockerAvailable) { - warmups.push(prefetchDeps({ mode: "docker" })); - } - await Promise.all(warmups); - } catch (error) { - logger.warn( - `[stack-e2e] Warmup failed: ${error instanceof Error ? error.message : String(error)}`, - ); - if (shouldFailOnError) { - throw error; + const modes: PrefetchOptions[] = [{ mode: "native" }]; + if (dockerAvailable) modes.push({ mode: "docker" }); + + for (const mode of modes) { + try { + await prefetchDeps(mode); + } catch (error) { + logger.warn( + `[stack-e2e] Warmup failed: ${error instanceof Error ? error.message : String(error)}`, + ); + if (shouldFailOnError) { + throw error; + } } } } diff --git a/packages/stack/tests/helpers/warmup.unit.test.ts b/packages/stack/tests/helpers/warmup.unit.test.ts index 5e805ffb0f..16ab704f69 100644 --- a/packages/stack/tests/helpers/warmup.unit.test.ts +++ b/packages/stack/tests/helpers/warmup.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import type { PrefetchOptions, PrefetchResult } from "../../src/node.ts"; import { warmStackE2eDependencies } from "./warmup.ts"; @@ -29,32 +29,20 @@ function makeResult(type: "binary" | "docker"): PrefetchResult { } describe("stack e2e warmup", () => { - test("runs auto prefetch and docker image warmup when Docker is available", async () => { + test("warms native and Docker resources when Docker is available", async () => { const calls: Array = []; const { logger } = makeLogger(); - let finishAutoPrefetch: (() => void) | undefined; - const warmup = warmStackE2eDependencies({ + await warmStackE2eDependencies({ logger, hasDockerDaemon: () => true, prefetch: async (options?: PrefetchOptions) => { calls.push(options); - if (options === undefined) { - await new Promise((resolve) => { - finishAutoPrefetch = resolve; - }); - } return options?.mode === "docker" ? makeResult("docker") : makeResult("binary"); }, }); - await vi.waitFor(() => { - expect(calls).toEqual([undefined, { mode: "docker" }]); - }); - finishAutoPrefetch?.(); - await warmup; - - expect(calls).toEqual([undefined, { mode: "docker" }]); + expect(calls).toEqual([{ mode: "native" }, { mode: "docker" }]); }); test("skips docker image warmup when Docker is unavailable", async () => { @@ -70,7 +58,7 @@ describe("stack e2e warmup", () => { }, }); - expect(calls).toEqual([undefined]); + expect(calls).toEqual([{ mode: "native" }]); }); test("can fail fast when warmup is required", async () => { @@ -89,6 +77,24 @@ describe("stack e2e warmup", () => { expect(warn.some((message) => message.includes("Warmup failed"))).toBe(true); }); + test("continues to the Docker warmup after a best-effort native failure", async () => { + const calls: Array = []; + const { warn, logger } = makeLogger(); + + await warmStackE2eDependencies({ + hasDockerDaemon: () => true, + logger, + prefetch: async (options?: PrefetchOptions) => { + calls.push(options); + if (options?.mode === "native") throw new Error("native unavailable"); + return makeResult("docker"); + }, + }); + + expect(calls).toEqual([{ mode: "native" }, { mode: "docker" }]); + expect(warn.some((message) => message.includes("native unavailable"))).toBe(true); + }); + test("only warns when warmup is best effort", async () => { const { warn, logger } = makeLogger(); diff --git a/packages/stack/tests/postgresDataPersistence.e2e.test.ts b/packages/stack/tests/postgresDataPersistence.e2e.test.ts index 3fa4ab86b0..7d7da780a7 100644 --- a/packages/stack/tests/postgresDataPersistence.e2e.test.ts +++ b/packages/stack/tests/postgresDataPersistence.e2e.test.ts @@ -51,11 +51,11 @@ async function queryMarkerRows(dbPort: number): Promise { INSERT INTO public.persistence_marker (note) VALUES ('native-e2e-marker'); `); - sql.close(); + await sql.close(); }, NATIVE_SETUP_TIMEOUT_MS); afterAll(async () => {