diff --git a/AGENTS.md b/AGENTS.md index edfd1fc3be..865c9cda93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,8 +64,10 @@ Key references: Write new runtime code Effect-native from the start; do not build a sync or Promise-based core and wrap it in Effect afterwards. Retrofitting Effect onto a Promise core is expensive and error-prone: it resurfaces as blocking waits where a `Schedule` belongs, interruption gaps around resource acquisition, and untyped failures leaking through `Effect.tryPromise`. - Model failures as `Data.TaggedError` classes with typed error channels, dependencies as services provided through `Layer`, retries/polling as `Schedule`s, and resource lifecycles with scopes and interruption-safe masks — never `Atomics.wait`, ad-hoc `setTimeout` loops, or manual try/finally resource juggling in core code. -- Expose Promise-based facades only at the outermost package edge (public entrypoints for non-Effect consumers), acquired asynchronously — never inside the core. -- A small leaf primitive with no Effect semantics of its own (a pure function, a single-syscall fs helper) may stay plain async and be wrapped at its call boundary; everything with failure modes, retries, resources, or concurrency belongs in Effect. +- Expose Promise-based facades only at the outermost package edge for non-Effect consumers. Do not use `async` functions, `new Promise`, Promise chains, Promise-based recursion, or `Effect.runPromise` inside the runtime core. +- A Promise in any other production location requires a documented, concrete reason why the operation cannot be expressed with Effect. Convenience, familiarity, or avoiding an Effect service is not a valid reason. +- Use `Effect.tryPromise` only at an unavoidable foreign Promise API boundary. Use its Effect-provided `AbortSignal` when the API supports cancellation, map failures into typed errors, and keep the Promise contained to that leaf boundary. +- Prefer Effect services for filesystem, process, network, timing, retry, concurrency, and resource-lifecycle work. A small synchronous leaf operation may remain a plain function when it has no Effect semantics; it must not become an asynchronous Promise core. ## Code Quality 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/logs/logs.e2e.test.ts b/apps/cli/src/next/commands/logs/logs.e2e.test.ts deleted file mode 100644 index 561f112811..0000000000 --- a/apps/cli/src/next/commands/logs/logs.e2e.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - makeTempHome, - makeTempStackProject, - runSupabase, - spawnSupabase, -} from "../../../../tests/helpers/cli.ts"; - -const LOGS_TIMEOUT_MS = 30_000; -const LOGS_IDLE_WINDOW_MS = 500; -const LIGHTWEIGHT_START_ARGS = [ - "start", - "--detach", - "--exclude", - "realtime", - "--exclude", - "storage", - "--exclude", - "imgproxy", - "--exclude", - "mailpit", - "--exclude", - "pgmeta", - "--exclude", - "studio", - "--exclude", - "analytics", - "--exclude", - "vector", - "--exclude", - "pooler", -] as const; - -function extractApiUrl(output: string): string { - const match = output.match(/API URL:\s+(http:\/\/\S+)/); - if (match?.[1] == null) { - throw new Error(`Could not find API URL in output:\n${output}`); - } - return match[1]; -} - -async function triggerAuthLog(apiUrl: string): Promise { - const response = await fetch(`${apiUrl}/auth/v1/signup`); - expect(response.status).toBe(405); -} - -async function waitForMatches( - proc: ReturnType, - pattern: RegExp, - count: number, - timeoutMs = LOGS_TIMEOUT_MS, -): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const matches = proc - .stdout() - .match(new RegExp(pattern.source, pattern.flags + (pattern.flags.includes("g") ? "" : "g"))); - if ((matches?.length ?? 0) >= count) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`Timed out waiting for ${count} matches of ${pattern}`); -} - -describe("supabase logs", () => { - test( - "prints buffered history on attach and keeps following after an idle period", - { timeout: LOGS_TIMEOUT_MS }, - async () => { - const home = makeTempHome(); - const project = await makeTempStackProject("supabase-logs-e2e-"); - let logsProc: ReturnType | undefined; - - try { - const startResult = await runSupabase([...LIGHTWEIGHT_START_ARGS], { - cwd: project.dir, - home: home.dir, - exitTimeoutMs: LOGS_TIMEOUT_MS, - }); - expect(startResult.exitCode).toBe(0); - const apiUrl = extractApiUrl(startResult.stdout); - - await triggerAuthLog(apiUrl); - - logsProc = spawnSupabase(["logs"], { - cwd: project.dir, - home: home.dir, - cleanupProcessGroupOnClose: false, - }); - - await waitForMatches(logsProc, /\[auth\].*"path":"\/signup"/, 1); - - await new Promise((resolve) => setTimeout(resolve, LOGS_IDLE_WINDOW_MS)); - await triggerAuthLog(apiUrl); - await waitForMatches(logsProc, /\[auth\].*"path":"\/signup"/, 2); - - logsProc.kill("SIGTERM"); - - const result = await logsProc.waitForExit(); - logsProc = undefined; - - expect(result.stderr).not.toContain("ECONNRESET"); - expect(result.stderr).not.toContain("The socket connection was closed unexpectedly"); - } finally { - logsProc?.kill("SIGTERM"); - await logsProc?.waitForExit().catch(() => {}); - } - }, - ); -}); 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..65658df4de 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,15 +11,15 @@ import { } from "../../config/service-version-resolution.ts"; describe("service version overrides", () => { - test("parses and normalizes repeated flag overrides", async () => { + test("canonicalizes repeated flag overrides to published service tags", async () => { await expect( Effect.runPromise( parseServiceVersionOverrides(["postgrest=v14.5", "mailpit=1.30.2", "auth=2.180.0"]), ), ).resolves.toEqual({ - postgrest: "14.5", + postgrest: "v14.5", mailpit: "v1.30.2", - auth: "2.180.0", + auth: "v2.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,13 +68,13 @@ describe("service version overrides", () => { runtimeVersions: { ...candidateBaseline, postgres: "17.4.1.045", - auth: "2.170.0", - storage: "1.40.0", + auth: "v2.170.0", + storage: "v1.40.0", }, activeOverrides: [ { service: "postgres", version: "17.4.1.045", source: "flag" }, - { service: "auth", version: "2.170.0", source: "flag" }, - { service: "storage", version: "1.40.0", source: "local" }, + { service: "auth", version: "v2.170.0", source: "flag" }, + { service: "storage", version: "v1.40.0", source: "local" }, ], availableUpdates: [], updateFingerprint: undefined, 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.e2e.test.ts b/apps/cli/src/next/commands/start/start.e2e.test.ts deleted file mode 100644 index 0b94ce743e..0000000000 --- a/apps/cli/src/next/commands/start/start.e2e.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { makeTempHome, makeTempStackProject, runSupabase } from "../../../../tests/helpers/cli.ts"; - -const DETACHED_START_TIMEOUT_MS = 30_000; -const LIGHTWEIGHT_START_ARGS = [ - "start", - "--detach", - "--exclude", - "realtime", - "--exclude", - "storage", - "--exclude", - "imgproxy", - "--exclude", - "mailpit", - "--exclude", - "pgmeta", - "--exclude", - "studio", - "--exclude", - "analytics", - "--exclude", - "vector", - "--exclude", - "pooler", -] as const; - -describe("supabase start", () => { - test( - "starts in detached mode and prints connection info", - { timeout: DETACHED_START_TIMEOUT_MS }, - async () => { - const home = makeTempHome(); - const project = await makeTempStackProject("supabase-start-e2e-"); - const { stdout, stderr, exitCode } = await runSupabase([...LIGHTWEIGHT_START_ARGS], { - cwd: project.dir, - home: home.dir, - exitTimeoutMs: DETACHED_START_TIMEOUT_MS, - }); - expect(exitCode, `stdout:\n${stdout}\nstderr:\n${stderr}`).toBe(0); - expect(stdout).toContain("Local Supabase started"); - expect(stdout).toContain("API URL:"); - expect(stdout).toContain("DB URL:"); - }, - ); - - test( - "reattaches when detached start is already running", - { timeout: DETACHED_START_TIMEOUT_MS }, - async () => { - const home = makeTempHome(); - const project = await makeTempStackProject("supabase-start-e2e-"); - const first = await runSupabase([...LIGHTWEIGHT_START_ARGS], { - cwd: project.dir, - home: home.dir, - exitTimeoutMs: DETACHED_START_TIMEOUT_MS, - }); - expect(first.exitCode, `stdout:\n${first.stdout}\nstderr:\n${first.stderr}`).toBe(0); - - const second = await runSupabase([...LIGHTWEIGHT_START_ARGS], { - cwd: project.dir, - home: home.dir, - exitTimeoutMs: DETACHED_START_TIMEOUT_MS, - }); - expect(second.exitCode, `stdout:\n${second.stdout}\nstderr:\n${second.stderr}`).toBe(0); - expect(second.stdout).toContain("Start local Supabase stack"); - expect(second.stdout).toContain("Local Supabase started"); - }, - ); -}); 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.e2e.test.ts b/apps/cli/src/next/commands/status/status.e2e.test.ts deleted file mode 100644 index fba5693eeb..0000000000 --- a/apps/cli/src/next/commands/status/status.e2e.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { makeTempHome, makeTempStackProject, runSupabase } from "../../../../tests/helpers/cli.ts"; - -const STATUS_TIMEOUT_MS = 30_000; -const LIGHTWEIGHT_START_ARGS = [ - "start", - "--detach", - "--exclude", - "realtime", - "--exclude", - "storage", - "--exclude", - "imgproxy", - "--exclude", - "mailpit", - "--exclude", - "pgmeta", - "--exclude", - "studio", - "--exclude", - "analytics", - "--exclude", - "vector", - "--exclude", - "pooler", -] as const; - -describe("supabase status", () => { - test( - "shows connection info and service states for the current project", - { timeout: STATUS_TIMEOUT_MS }, - async () => { - const home = makeTempHome(); - const project = await makeTempStackProject("supabase-status-e2e-"); - const startResult = await runSupabase([...LIGHTWEIGHT_START_ARGS], { - cwd: project.dir, - home: home.dir, - exitTimeoutMs: STATUS_TIMEOUT_MS, - }); - expect(startResult.exitCode).toBe(0); - - const result = await runSupabase(["status"], { cwd: project.dir, home: home.dir }); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Show local Supabase stack status"); - expect(result.stdout).toContain("Local Supabase stack is running."); - expect(result.stdout).toContain("API URL:"); - expect(result.stdout).toContain("DB URL:"); - expect(result.stdout).toContain("Publishable key:"); - expect(result.stdout).toContain("Secret key:"); - expect(result.stdout).toContain("auth:"); - expect(result.stdout).toContain("postgres:"); - expect(result.stdout).not.toContain("Stack status"); - expect(result.stdout).not.toContain("(running) -"); - }, - ); -}); 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/stop/stop.e2e.test.ts b/apps/cli/src/next/commands/stop/stop.e2e.test.ts deleted file mode 100644 index c235392a62..0000000000 --- a/apps/cli/src/next/commands/stop/stop.e2e.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { existsSync, readdirSync } from "node:fs"; -import { join } from "node:path"; -import { makeTempHome, makeTempStackProject, runSupabase } from "../../../../tests/helpers/cli.ts"; - -const LIGHTWEIGHT_START_ARGS = [ - "start", - "--detach", - "--exclude", - "realtime", - "--exclude", - "storage", - "--exclude", - "imgproxy", - "--exclude", - "mailpit", - "--exclude", - "pgmeta", - "--exclude", - "studio", - "--exclude", - "analytics", - "--exclude", - "vector", - "--exclude", - "pooler", -] as const; -const STOP_STACK_TIMEOUT_MS = 30_000; - -function managedStackDir(homeDir: string): string { - const stacksRoot = join(homeDir, "managed", "stacks"); - const stackIds = existsSync(stacksRoot) - ? readdirSync(stacksRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - : []; - expect(stackIds).toHaveLength(1); - return join(stacksRoot, stackIds[0]!); -} - -describe("supabase stop", () => { - test( - "preserves the persisted stack folder by default", - { timeout: STOP_STACK_TIMEOUT_MS }, - async () => { - const home = makeTempHome(); - const project = await makeTempStackProject("supabase-stop-e2e-"); - - const startResult = await runSupabase([...LIGHTWEIGHT_START_ARGS], { - cwd: project.dir, - home: home.dir, - }); - expect(startResult.exitCode).toBe(0); - const stackDir = managedStackDir(home.dir); - - const stopResult = await runSupabase(["stop"], { cwd: project.dir, home: home.dir }); - expect(stopResult.exitCode).toBe(0); - expect(existsSync(stackDir)).toBe(true); - expect(existsSync(join(stackDir, "stack.json"))).toBe(true); - }, - ); - - test( - "deletes the persisted stack folder with --no-backup", - { timeout: STOP_STACK_TIMEOUT_MS }, - async () => { - const home = makeTempHome(); - const project = await makeTempStackProject("supabase-stop-e2e-"); - - const startResult = await runSupabase([...LIGHTWEIGHT_START_ARGS], { - cwd: project.dir, - home: home.dir, - }); - expect(startResult.exitCode).toBe(0); - const stackDir = managedStackDir(home.dir); - - const stopResult = await runSupabase(["stop", "--no-backup"], { - cwd: project.dir, - home: home.dir, - }); - expect( - stopResult.exitCode, - `stdout:\n${stopResult.stdout}\n\nstderr:\n${stopResult.stderr}`, - ).toBe(0); - expect(existsSync(stackDir)).toBe(false); - }, - ); -}); 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..2fad3f626f 100644 --- a/packages/process-compose/tests/helpers/mocks.ts +++ b/packages/process-compose/tests/helpers/mocks.ts @@ -13,14 +13,22 @@ const isOneShotSupervisor = (args: ReadonlyArray): boolean => { if (encoded === undefined) return false; try { const config: unknown = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); + if ( + typeof config !== "object" || + config === null || + !("command" in config) || + !("args" in config) || + !Array.isArray(config.args) + ) { + return false; + } + const bashScript = + config.command === "bash" && config.args[0] === "-c" && typeof config.args[1] === "string" + ? config.args[1] + : undefined; return ( - typeof config === "object" && - config !== null && - "command" in config && - config.command === "bash" && - "args" in config && - Array.isArray(config.args) && - config.args[0] === "-c" + (bashScript !== undefined && !/(?:^|\n)\s*exec\s/.test(bashScript)) || + ((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..7dc4e50272 --- /dev/null +++ b/packages/stack/src/ContainerRuntime.integration.test.ts @@ -0,0 +1,92 @@ +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", + }); + }).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", + }); + }).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..f2d83d5576 --- /dev/null +++ b/packages/stack/src/ContainerRuntime.ts @@ -0,0 +1,75 @@ +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 = ["docker", "podman"] as const satisfies ReadonlyArray; + const probes = yield* Effect.all( + runtimes.map((runtime) => + probeContainerRuntime(runtime).pipe(Effect.map((usable) => [runtime, usable] as const)), + ), + { concurrency: "unbounded" }, + ); + const selected = probes.find(([, usable]) => usable)?.[0]; + if (selected !== undefined) { + return { mode: "docker", containerRuntime: selected }; + } + + 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/HttpTransportClient.integration.test.ts b/packages/stack/src/HttpTransportClient.integration.test.ts new file mode 100644 index 0000000000..6d1fcad6b3 --- /dev/null +++ b/packages/stack/src/HttpTransportClient.integration.test.ts @@ -0,0 +1,97 @@ +import { Effect, Fiber, ManagedRuntime } from "effect"; +import type { Socket } from "node:net"; +import { createServer, type Server } from "node:http"; +import { afterEach, describe, expect, test } from "vitest"; +import { HttpTransportClient, httpTransportClientLayer } from "./HttpTransportClient.ts"; +import type { ControlEndpoint } from "./managed/control.ts"; + +const endpointFor = (server: Server): ControlEndpoint => { + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("Expected TCP address"); + return { + hostname: "127.0.0.1", + port: address.port, + url: `http://127.0.0.1:${address.port}`, + }; +}; + +const listen = (server: Server): Promise => + new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + +const close = (server: Server): Promise => + new Promise((resolve, reject) => { + if (!server.listening) { + resolve(); + return; + } + server.close((error) => (error === undefined ? resolve() : reject(error))); + }); + +const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +}; + +describe("HttpTransportClient", () => { + let server: Server | undefined; + let activeSocket: Socket | undefined; + + afterEach(async () => { + activeSocket?.destroy(); + if (server !== undefined) await close(server); + activeSocket = undefined; + server = undefined; + }); + + test("closes an unanswered request when its fiber is interrupted", async () => { + let requestArrived!: () => void; + const requestReady = new Promise((resolve) => { + requestArrived = resolve; + }); + let closed = false; + let connectionClosed!: () => void; + const connectionClosedPromise = new Promise((resolve) => { + connectionClosed = () => { + closed = true; + resolve(); + }; + }); + + server = createServer((_request, response) => { + const socket = response.socket; + if (socket === null) throw new Error("Expected request socket"); + activeSocket = socket; + requestArrived(); + socket.once("close", connectionClosed); + }); + await listen(server); + + const runtime = ManagedRuntime.make(httpTransportClientLayer); + try { + const fiber = runtime.runFork( + Effect.gen(function* () { + const client = yield* HttpTransportClient; + yield* client.request(endpointFor(server!), "/never"); + }), + ); + await requestReady; + await runtime.runPromise(Fiber.interrupt(fiber)); + await withTimeout(connectionClosedPromise, 5_000); + expect(closed).toBe(true); + } finally { + await runtime.dispose(); + } + }); +}); diff --git a/packages/stack/src/HttpTransportClient.ts b/packages/stack/src/HttpTransportClient.ts index d9b38a7717..e65a7f3c2a 100644 --- a/packages/stack/src/HttpTransportClient.ts +++ b/packages/stack/src/HttpTransportClient.ts @@ -22,13 +22,14 @@ export class HttpTransportClient extends Context.Service< export const httpTransportClientLayer = Layer.succeed(HttpTransportClient, { request: (endpoint, path, init) => Effect.tryPromise({ - try: () => + try: (signal) => fetch(`${endpoint.url}${path}`, { ...init, - signal: - init?.signal == null - ? AbortSignal.timeout(30_000) - : AbortSignal.any([init.signal, AbortSignal.timeout(30_000)]), + signal: AbortSignal.any( + init?.signal === undefined || init.signal === null + ? [signal, AbortSignal.timeout(30_000)] + : [signal, init.signal], + ), }), catch: (cause) => new HttpTransportClientError({ endpoint, path, cause, reason: "transport" }), diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts index 8897c679b9..effed2c4ae 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,143 @@ 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 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 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 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 || state.status === "Downloading" + ? [] + : [[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 +415,97 @@ 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; - }), - ), - ); - - return Effect.gen(function* () { - yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), scope); - return yield* Deferred.await(deferred); - }); - }); + const deferred = Deferred.makeUnsafe(); + runtimeDeferred = 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) + .pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Scope.Scope, scope), + ); + 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 +583,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 +707,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 +739,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 +771,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 +851,7 @@ export const localStackLayer = ( ); return; } + yield* prepareServices([service]); const started = yield* Effect.gen(function* () { yield* requireRunningPhase; const concurrentlyStarted = yield* inspectStartedTargets(service); @@ -708,13 +876,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 +904,39 @@ 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)), + // Whole-stack startup owns every enabled service, including + // lazy transitive dependencies of eager services (for + // example Studio -> pgmeta). Explicit startService calls + // keep their narrower activation allowlist below. + new Set(enabledServices), ); 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 +956,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 +973,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 +988,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 +1003,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,18 +1032,31 @@ 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 buildResult = yield* builder + .build(nextConfig, prepared) + .pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Scope.Scope, scope), + ); const edgeRuntimeDef = buildResult.graph.startOrder.find( (def) => def.name === "edge-runtime", ); 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..da826b45df 100644 --- a/packages/stack/src/PortAllocator.integration.test.ts +++ b/packages/stack/src/PortAllocator.integration.test.ts @@ -1,159 +1,165 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; import { createServer, type Server } from "node:net"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { Effect } from "effect"; -import { allocatePortSet, reservePortSet, type PortReservationRequest } from "./PortAllocator.ts"; +import { NodeFileSystem } from "@effect/platform-node"; +import { Cause, Effect, Exit, FileSystem } from "effect"; +import { reservePortSet, type PortReservationRequest } from "./PortAllocator.ts"; -const listen = (port: number) => - Effect.callback((resume) => { - const server = createServer(); - server.once("error", (error) => resume(Effect.fail(error))); - server.listen(port, "127.0.0.1", () => resume(Effect.succeed(server))); - return Effect.void; - }); - -const close = (server: Server) => - Effect.callback((resume) => { - server.close(() => resume(Effect.void)); - return Effect.void; - }); - -const occupyFreePort = () => - Effect.acquireRelease( - Effect.map(listen(0), (server) => { - const address = server.address(); - if (address === null || typeof address === "string") { - throw new Error("Expected TCP server address"); - } - return { port: address.port, server }; - }), - ({ server }) => close(server), - ); +const PORT_LEASE_CHILD = resolve(import.meta.dirname, "../tests/helpers/port-lease-child.ts"); const automatic = (field: PortReservationRequest["field"]): PortReservationRequest => ({ field, selection: { kind: "automatic" }, }); -describe("selected-field port allocation", () => { - it("holds only requested fields and can release and re-reserve them", async () => { - const lease = await Effect.runPromise( - reservePortSet([automatic("apiPort"), automatic("dbPort")]), - ); - - try { - expect(lease.ports.apiPort).toBeGreaterThan(0); - expect(lease.ports.dbPort).toBeGreaterThan(0); - expect("authPort" in lease.ports).toBe(false); - - const unavailable = await Effect.runPromise( - allocatePortSet([ - { field: "apiPort", selection: { kind: "exact", port: lease.ports.apiPort! } }, - ]).pipe(Effect.exit), - ); - expect(unavailable._tag).toBe("Failure"); - - await Effect.runPromise(lease.release(["apiPort"])); - const rebound = await Effect.runPromise( - allocatePortSet([ - { field: "apiPort", selection: { kind: "exact", port: lease.ports.apiPort! } }, - ]), - ); - expect(rebound.apiPort).toBe(lease.ports.apiPort); +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer))); - await Effect.runPromise(lease.reserve(["apiPort"])); - const unavailableAgain = await Effect.runPromise( - allocatePortSet([ - { field: "apiPort", selection: { kind: "exact", port: lease.ports.apiPort! } }, - ]).pipe(Effect.exit), - ); - expect(unavailableAgain._tag).toBe("Failure"); +const occupyFreePort = () => + Effect.acquireRelease( + Effect.callback((resume) => { + const server = createServer(); + server.once("error", (error) => resume(Effect.fail(error))); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + return Effect.sync(() => server.close()); + }).pipe( + Effect.map((server) => { + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("Expected address"); + return { port: address.port, server }; + }), + ), + ({ server }) => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + return Effect.void; + }), + ); - await Effect.runPromise(lease.releaseAll); - const reboundBoth = await Effect.runPromise( - allocatePortSet([ - { field: "apiPort", selection: { kind: "exact", port: lease.ports.apiPort! } }, - { field: "dbPort", selection: { kind: "exact", port: lease.ports.dbPort! } }, - ]), - ); - expect(reboundBoth.apiPort).toBe(lease.ports.apiPort); - expect(reboundBoth.dbPort).toBe(lease.ports.dbPort); - } finally { - await Effect.runPromise(lease.releaseAll); - } +const startChildLease = () => { + const child = spawn("bun", ["run", PORT_LEASE_CHILD], { + stdio: ["pipe", "pipe", "pipe"], }); + const ready = new Promise<{ readonly apiPort: number; readonly dbPort: number }>( + (resolveReady, rejectReady) => { + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + const newline = stdout.indexOf("\n"); + if (newline === -1) return; + try { + resolveReady(JSON.parse(stdout.slice(0, newline))); + } catch (error) { + rejectReady(new Error(`Invalid child response: ${stdout}`, { cause: error })); + } + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.once("error", rejectReady); + child.once("close", (code) => { + rejectReady(new Error(`Child exited before readiness (${code}): ${stderr}`)); + }); + }, + ); + return { child, ready }; +}; - it("fails when an exact port is occupied", async () => { - const exit = await Effect.runPromise( +describe("reservePortSet", () => { + it("fails an occupied exact port with field and port attribution", async () => { + let occupiedPort = 0; + const exit = await run( Effect.scoped( Effect.gen(function* () { const occupied = yield* occupyFreePort(); - return yield* allocatePortSet([ + occupiedPort = occupied.port; + return yield* reservePortSet([ { field: "apiPort", selection: { kind: "exact", port: occupied.port } }, ]).pipe(Effect.exit); }), ), ); - - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("is not available"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toMatchObject({ field: "apiPort", port: occupiedPort }); } }); - it("keeps concurrent selected-field leases disjoint", async () => { - const [first, second] = await Promise.all([ - Effect.runPromise(reservePortSet([automatic("apiPort"), automatic("dbPort")])), - Effect.runPromise(reservePortSet([automatic("apiPort"), automatic("dbPort")])), - ]); + it("reserves multiple automatic fields and re-reserves selected fields", async () => { + const lease = await run(reservePortSet([automatic("apiPort"), automatic("dbPort")])); + try { + expect(lease.ports.apiPort).toBeGreaterThan(0); + expect(lease.ports.dbPort).toBeGreaterThan(0); + await run(lease.release(["dbPort"])); + await run(lease.reserve(["dbPort"])); + } finally { + await run(lease.releaseAll); + } + }); + it("retains claims after TCP release until releaseAll", async () => { + const lease = await run(reservePortSet([automatic("apiPort")])); + const port = lease.ports.apiPort; + if (port === undefined) throw new Error("Expected API port"); try { - const firstPorts = new Set(Object.values(first.ports)); - expect(Object.values(second.ports).every((port) => !firstPorts.has(port))).toBe(true); + await run(lease.release(["apiPort"])); + const blocked = await run( + reservePortSet([{ field: "apiPort", selection: { kind: "exact", port } }]).pipe( + Effect.exit, + ), + ); + expect(Exit.isFailure(blocked)).toBe(true); } finally { - await Promise.all([ - Effect.runPromise(first.releaseAll), - Effect.runPromise(second.releaseAll), - ]); + await run(lease.releaseAll); } }); - it("releases partial reservations when a selected set fails", async () => { - const firstPort = await Effect.runPromise( - Effect.scoped(Effect.map(occupyFreePort(), (occupied) => occupied.port)), + it("keeps automatic ports disjoint across child processes", async () => { + const first = startChildLease(); + const second = startChildLease(); + try { + const [left, right] = await Promise.all([first.ready, second.ready]); + const ports = [left.apiPort, left.dbPort, right.apiPort, right.dbPort]; + expect(new Set(ports).size).toBe(ports.length); + } finally { + for (const child of [first.child, second.child]) { + if (child.exitCode === null) { + child.stdin.end("release\n"); + await once(child, "close"); + } + } + } + }, 30_000); + + it("recovers a stale claim left by an unclean child exit", async () => { + const child = startChildLease(); + const ports = await child.ready; + child.child.kill("SIGKILL"); + await once(child.child, "close"); + const lease = await run( + reservePortSet([{ field: "apiPort", selection: { kind: "exact", port: ports.apiPort } }]), ); - const failed = await Effect.runPromise( + await run(lease.releaseAll); + }, 30_000); + + it("rolls back earlier fields when a later exact field is unavailable", async () => { + const failed = await run( Effect.scoped( Effect.gen(function* () { const occupied = yield* occupyFreePort(); return yield* reservePortSet([ - { field: "apiPort", selection: { kind: "exact", port: firstPort } }, + automatic("apiPort"), { field: "dbPort", selection: { kind: "exact", port: occupied.port } }, ]).pipe(Effect.exit); }), ), ); - expect(failed._tag).toBe("Failure"); - - const available = await Effect.runPromise( - allocatePortSet([{ field: "apiPort", selection: { kind: "exact", port: firstPort } }]), - ); - expect(available.apiPort).toBe(firstPort); - }); - - it("releases a bound port when interrupted during lease registration", async () => { - const port = await Effect.runPromise( - Effect.scoped(Effect.map(occupyFreePort(), (occupied) => occupied.port)), - ); - const interrupted = await Effect.runPromise( - reservePortSet([{ field: "apiPort", selection: { kind: "exact", port } }], { - onBound: () => Effect.interrupt, - }).pipe(Effect.exit), - ); - expect(interrupted._tag).toBe("Failure"); - - const rebound = await Effect.runPromise( - allocatePortSet([{ field: "apiPort", selection: { kind: "exact", port } }]), - ); - expect(rebound.apiPort).toBe(port); + expect(Exit.isFailure(failed)).toBe(true); + const retry = await run(reservePortSet([automatic("apiPort")])); + await run(retry.releaseAll); }); }); diff --git a/packages/stack/src/PortAllocator.ts b/packages/stack/src/PortAllocator.ts index 6eb7b3ff58..dcc1561e0c 100644 --- a/packages/stack/src/PortAllocator.ts +++ b/packages/stack/src/PortAllocator.ts @@ -1,5 +1,9 @@ +import { randomUUID } from "node:crypto"; import { createServer, type Server } from "node:net"; -import { Data, Effect, Schema, Semaphore } from "effect"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Cause, Data, Effect, FileSystem, Option, Schema, Semaphore } from "effect"; +import { PlatformError } from "effect/PlatformError"; import { PortSetSchema, type PortField, type PortSet } from "./PortCatalog.ts"; export class PortAllocationError extends Data.TaggedError("PortAllocationError")<{ @@ -7,15 +11,27 @@ export class PortAllocationError extends Data.TaggedError("PortAllocationError") readonly cause?: unknown; readonly field?: PortField; readonly port?: number; + readonly reason?: "unavailable" | "failed"; }> { override get message(): string { return this.detail; } } +class PortClaimCollisionError extends Data.TaggedError("PortClaimCollisionError")<{ + readonly port: number; +}> {} + +const MAX_CLAIM_ATTEMPTS = 32; + export type PortSelection = | { readonly kind: "exact"; readonly port: number } - | { readonly kind: "automatic"; readonly preferred?: number }; + | { + readonly kind: "automatic"; + readonly preferred?: number; + /** Additional exclusions used by managed allocation (for example control ports). */ + readonly excluded?: ReadonlySet; + }; export interface PortReservationRequest { readonly field: PortField; @@ -26,88 +42,274 @@ export interface PortSelectionOptions { readonly reserved?: ReadonlySet; } -interface PortAllocationOptions extends PortSelectionOptions { - readonly probe?: PortProbe; - /** @internal deterministic interruption hook used by allocator integration tests. */ - readonly onBound?: (field: PortField, bound: BoundPort) => Effect.Effect; +const closeServer = (server: Server): Effect.Effect => + Effect.callback((resume) => { + if (!server.listening) { + resume(Effect.void); + return Effect.void; + } + server.close((cause) => resume(cause === undefined ? Effect.void : Effect.die(cause))); + return Effect.void; + }); + +interface BoundPort { + readonly port: number; + readonly server: Server; } -interface PortProbe { - readonly exact: (port: number) => Effect.Effect; - readonly random: (exclude: ReadonlySet) => Effect.Effect; +interface PortClaim { + readonly path: string; + readonly port: number; + readonly token: string; } -/** Bind port 0 to get an OS-assigned random port, then close immediately. */ -const probeRandomPort = ( - exclude: ReadonlySet, -): Effect.Effect => - Effect.flatMap( - Effect.callback((resume) => { - const server = createServer(); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = typeof address === "object" && address !== null ? address.port : 0; - server.close(() => resume(Effect.succeed(port))); - }); - server.on("error", (cause) => - resume( - Effect.fail(new PortAllocationError({ detail: "Failed to bind random port", cause })), +interface ClaimRecord { + readonly pid: number; + readonly token: string; +} + +interface ClaimSnapshot { + readonly contents: string; + readonly record: ClaimRecord | undefined; + readonly info: FileSystem.File.Info; +} + +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, root = CLAIM_ROOT): string => join(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 isNotFound = (error: PlatformError): boolean => error.reason._tag === "NotFound"; +const isAlreadyExists = (error: PlatformError): boolean => error.reason._tag === "AlreadyExists"; + +const parseClaimRecord = (contents: string): ClaimRecord | undefined => { + 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 readClaimSnapshot = ( + path: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const contents = yield* fs + .readFileString(path) + .pipe( + Effect.catchTag("PlatformError", (error) => + isNotFound(error) ? Effect.succeed(undefined) : Effect.fail(error), ), ); - return Effect.void; - }), - (port) => (exclude.has(port) ? probeRandomPort(exclude) : Effect.succeed(port)), + if (contents === undefined) return undefined; + const info = yield* fs + .stat(path) + .pipe( + Effect.catchTag("PlatformError", (error) => + isNotFound(error) ? Effect.succeed(undefined) : Effect.fail(error), + ), + ); + if (info === undefined) return undefined; + return { contents, record: parseClaimRecord(contents), info }; + }); + +const claimIsStale = (snapshot: ClaimSnapshot): boolean => { + if (snapshot.info.type !== "File") return false; + if (snapshot.record !== undefined) return !isProcessAlive(snapshot.record.pid); + return ( + Option.isSome(snapshot.info.mtime) && + Date.now() - snapshot.info.mtime.value.getTime() > CLAIM_STALE_AFTER_MS ); +}; -/** Probe the exact port requested by the user. Fail if it is not available. */ -const probeExactPort = (port: number): Effect.Effect => - Effect.callback((resume) => { - const server = createServer(); - server.listen(port, "127.0.0.1", () => { - server.close(() => resume(Effect.succeed(port))); - }); - server.on("error", () => - resume( - Effect.fail(new PortAllocationError({ detail: `Port ${port} is not available`, port })), - ), +const inspectClaim = ( + path: string, +): Effect.Effect< + { readonly snapshot: ClaimSnapshot; readonly stale: boolean } | undefined, + PlatformError, + FileSystem.FileSystem +> => + readClaimSnapshot(path).pipe( + Effect.map((snapshot) => + snapshot === undefined ? undefined : { snapshot, stale: claimIsStale(snapshot) }, + ), + ); + +const claimIdentityMatches = (expected: ClaimSnapshot, current: ClaimSnapshot): boolean => { + if (expected.record !== undefined || current.record !== undefined) { + return ( + expected.record !== undefined && + current.record !== undefined && + expected.record.pid === current.record.pid && + expected.record.token === current.record.token ); - return Effect.void; + } + if (expected.contents !== current.contents) return false; + if (Option.isSome(expected.info.ino) && Option.isSome(current.info.ino)) { + return expected.info.ino.value === current.info.ino.value; + } + return false; +}; + +const removeCreatedClaim = ( + path: string, + contents: string, + openedInfo: FileSystem.File.Info | undefined, + opened: boolean, + fs: FileSystem.FileSystem, +): Effect.Effect => + Effect.gen(function* () { + if (!opened) return; + const current = yield* readClaimSnapshot(path).pipe(Effect.orElseSucceed(() => undefined)); + if (current === undefined) return; + if (openedInfo === undefined) { + if (current.contents.length !== 0) return; + } else if (Option.isSome(openedInfo.ino) && Option.isSome(current.info.ino)) { + if (openedInfo.ino.value !== current.info.ino.value) return; + } else { + if (current.contents !== contents) return; + if ( + !Option.isSome(openedInfo.mtime) || + !Option.isSome(current.info.mtime) || + openedInfo.mtime.value.getTime() !== current.info.mtime.value.getTime() + ) { + return; + } + } + yield* fs.remove(path, { force: true }).pipe(Effect.ignore); + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)); + +const readClaimRecord = ( + path: string, +): Effect.Effect => + readClaimSnapshot(path).pipe(Effect.map((snapshot) => snapshot?.record)); + +const removeStaleClaim = ( + path: string, + expected: ClaimSnapshot, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const current = yield* readClaimSnapshot(path).pipe(Effect.orElseSucceed(() => undefined)); + if (current === undefined || !claimIdentityMatches(expected, current)) return false; + yield* fs + .remove(path, { force: true }) + .pipe( + Effect.catchTag("PlatformError", (error) => + isNotFound(error) ? Effect.void : Effect.fail(error), + ), + ); + return true; }); -const chooseExactPort = ( +const acquirePortClaimInternal = ( port: number, - exclude: ReadonlySet, - probe: PortProbe, -): Effect.Effect => - exclude.has(port) - ? Effect.fail(new PortAllocationError({ detail: `Port ${port} is not available`, port })) - : probe.exact(port); + root = CLAIM_ROOT, +): Effect.Effect< + PortClaim, + PlatformError | PortAllocationError | PortClaimCollisionError, + FileSystem.FileSystem +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(root, { recursive: true }); + const path = claimPath(port, root); + const token = randomUUID(); + const contents = JSON.stringify({ pid: process.pid, token }); + + for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS; attempt += 1) { + let openedInfo: FileSystem.File.Info | undefined; + let created = false; + const openedExit = yield* Effect.exit( + Effect.scoped( + fs.open(path, { flag: "wx", mode: 0o600 }).pipe( + Effect.flatMap((handle) => { + created = true; + return handle.stat.pipe( + Effect.tap((info) => Effect.sync(() => (openedInfo = info))), + Effect.andThen(handle.writeAll(new TextEncoder().encode(contents))), + ); + }), + ), + ), + ); + if (openedExit._tag === "Success") return { path, port, token }; + yield* Effect.uninterruptible(removeCreatedClaim(path, contents, openedInfo, created, fs)); + const failure = Cause.findErrorOption(openedExit.cause); + if (Option.isNone(failure)) return yield* Effect.failCause(openedExit.cause); + if (!isAlreadyExists(failure.value)) { + return yield* Effect.fail(failure.value); + } + const inspection = yield* inspectClaim(path); + if (inspection === undefined) continue; + if (!inspection.stale) { + return yield* new PortClaimCollisionError({ port }); + } + if (!(yield* removeStaleClaim(path, inspection.snapshot))) continue; + } + return yield* new PortAllocationError({ + detail: `Failed to claim port ${port} after ${MAX_CLAIM_ATTEMPTS} attempts`, + port, + reason: "failed", + }); + }); + +const portAllocationFromCause = (port: number, cause: unknown): PortAllocationError => + cause instanceof PortAllocationError + ? cause + : new PortAllocationError({ + detail: `Failed to claim port ${port}`, + cause, + port, + reason: "failed", + }); -const choosePreferredPort = ( +const acquirePortClaim = ( port: number, - exclude: ReadonlySet, - probe: PortProbe, -): Effect.Effect => - exclude.has(port) - ? probe.random(exclude) - : probe.exact(port).pipe(Effect.catchTag("PortAllocationError", () => probe.random(exclude))); - -const defaultPortProbe: PortProbe = { - exact: probeExactPort, - random: probeRandomPort, -}; + root = CLAIM_ROOT, +): Effect.Effect => + acquirePortClaimInternal(port, root).pipe( + Effect.mapError((cause) => + cause instanceof PortClaimCollisionError ? cause : portAllocationFromCause(port, cause), + ), + ); -const closeServer = (server: Server): Effect.Effect => - Effect.callback((resume) => { - server.close(() => resume(Effect.void)); - return Effect.void; +const releasePortClaim = (claim: PortClaim, fs: FileSystem.FileSystem): Effect.Effect => + Effect.gen(function* () { + const record = yield* readClaimRecord(claim.path).pipe( + Effect.orElseSucceed(() => undefined), + Effect.provideService(FileSystem.FileSystem, fs), + ); + if (record?.token !== claim.token || record.pid !== process.pid) return; + yield* fs.remove(claim.path, { force: true }).pipe(Effect.ignore); }); -interface BoundPort { - readonly port: number; - readonly server: Server; -} - const bindPort = (port: number): Effect.Effect => Effect.callback((resume) => { const server = createServer((socket) => socket.destroy()); @@ -118,6 +320,7 @@ const bindPort = (port: number): Effect.Effect = detail: port === 0 ? "Failed to reserve a random port" : `Port ${port} is not available`, cause, + reason: port === 0 ? "failed" : "unavailable", ...(port === 0 ? {} : { port }), }), ), @@ -128,10 +331,13 @@ const bindPort = (port: number): Effect.Effect = server.off("error", onError); const address = server.address(); if (address === null || typeof address === "string") { - void Effect.runPromise(closeServer(server)); resume( - Effect.fail( - new PortAllocationError({ detail: "Reserved TCP port has no numeric address" }), + closeServer(server).pipe( + Effect.andThen( + Effect.fail( + new PortAllocationError({ detail: "Reserved TCP port has no numeric address" }), + ), + ), ), ); return; @@ -144,7 +350,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; } @@ -177,19 +385,70 @@ const releaseReservations = ( (field) => { const server = reservations.get(field); if (server === undefined) return Effect.void; - reservations.delete(field); - return closeServer(server); + return Effect.uninterruptible( + closeServer(server).pipe(Effect.tap(() => Effect.sync(() => reservations.delete(field)))), + ); + }, + { discard: true }, + ); + +const releaseClaims = ( + claims: Map, + fields: ReadonlyArray, + fs: FileSystem.FileSystem, +): Effect.Effect => + Effect.forEach( + uniquePortFields(fields), + (field) => { + const claim = claims.get(field); + if (claim === undefined) return Effect.void; + return Effect.uninterruptible( + releasePortClaim(claim, fs).pipe(Effect.tap(() => Effect.sync(() => claims.delete(field)))), + ); }, { discard: true }, ); +const claimAndBind = ( + field: PortField, + port: number, + claims: Map, + fs: FileSystem.FileSystem, + root: string, +): Effect.Effect => { + const existingClaim = claims.get(field); + return Effect.gen(function* () { + const bound = yield* Effect.interruptible(bindPort(port)); + if (existingClaim !== undefined) { + claims.set(field, existingClaim); + return bound; + } + + const claimExit = yield* Effect.exit( + Effect.interruptible( + acquirePortClaim(port, root).pipe(Effect.provideService(FileSystem.FileSystem, fs)), + ), + ); + if (claimExit._tag === "Failure") { + yield* closeServer(bound.server); + return yield* Effect.failCause(claimExit.cause); + } + yield* Effect.uninterruptible(Effect.sync(() => claims.set(field, claimExit.value))); + return bound; + }); +}; + const reserveReservations = ( ports: PortSet, reservations: Map, + claims: Map, fields: ReadonlyArray, + fs: FileSystem.FileSystem, + root: string, ): Effect.Effect => Effect.suspend(() => { const acquired: Array = []; + const acquiredClaims: Array = []; return Effect.forEach( uniquePortFields(fields), (field) => { @@ -203,52 +462,123 @@ const reserveReservations = ( }), ); } - return bindPort(port).pipe( - Effect.mapError((error) => withPortField(field, error)), - Effect.tap(({ server }) => - Effect.sync(() => { - reservations.set(field, server); - acquired.push(field); - }), + const existingClaim = claims.has(field); + return Effect.uninterruptibleMask(() => + claimAndBind(field, port, claims, fs, root).pipe( + Effect.mapError((error) => + error instanceof PortClaimCollisionError + ? new PortAllocationError({ + detail: `Port ${error.port} is not available`, + field, + port: error.port, + reason: "unavailable", + }) + : 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, fs)], + { discard: true }, + ), + ), + ); }); -const makePortLease = (ports: PortSet, reservations: Map): PortLease => { +const makePortLease = ( + ports: PortSet, + reservations: Map, + claims: Map, + fs: FileSystem.FileSystem, + root: string, +): 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, fs, root)), 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()], fs), + ], + { discard: true }, + ), + ), ), }; }; const reserveRandomPort = ( exclude: ReadonlySet, -): Effect.Effect => - Effect.flatMap(bindPort(0), (bound) => - exclude.has(bound.port) - ? closeServer(bound.server).pipe(Effect.andThen(reserveRandomPort(exclude))) - : Effect.succeed(bound), - ); - -const resolveSelection = ( - selection: PortSelection, - exclude: ReadonlySet, - probe: PortProbe, -): Effect.Effect => { - if (selection.kind === "exact") { - return chooseExactPort(selection.port, exclude, probe); + field: PortField, + claims: Map, + fs: FileSystem.FileSystem, + root: string, + attempt = 0, +): Effect.Effect< + BoundPort, + PortAllocationError | PortClaimCollisionError, + FileSystem.FileSystem +> => { + if (attempt >= MAX_CLAIM_ATTEMPTS) { + return Effect.fail( + new PortAllocationError({ + detail: `Failed to reserve a random port after ${MAX_CLAIM_ATTEMPTS} claim collisions`, + reason: "failed", + }), + ); } - return selection.preferred === undefined - ? probe.random(exclude) - : choosePreferredPort(selection.preferred, exclude, probe); + return Effect.gen(function* () { + const bound = yield* Effect.interruptible(bindPort(0)); + if (exclude.has(bound.port)) { + yield* closeServer(bound.server); + return yield* reserveRandomPort(exclude, field, claims, fs, root, attempt + 1); + } + + const claimExit = yield* Effect.exit( + Effect.interruptible( + acquirePortClaim(bound.port, root).pipe(Effect.provideService(FileSystem.FileSystem, fs)), + ), + ); + if (claimExit._tag === "Success") { + yield* Effect.uninterruptible(Effect.sync(() => claims.set(field, claimExit.value))); + return bound; + } + + yield* closeServer(bound.server); + const failure = Cause.findErrorOption(claimExit.cause); + if (Option.isSome(failure) && failure.value instanceof PortClaimCollisionError) { + return yield* reserveRandomPort(exclude, field, claims, fs, root, attempt + 1); + } + if (Option.isSome(failure) && failure.value instanceof PlatformError) { + return yield* Effect.fail(portAllocationFromCause(bound.port, failure.value)); + } + if (Option.isSome(failure) && failure.value instanceof PortAllocationError) { + return yield* Effect.fail(failure.value); + } + return yield* Effect.failCause( + Cause.map(claimExit.cause, (error) => + error instanceof PortClaimCollisionError || error instanceof PortAllocationError + ? error + : portAllocationFromCause(bound.port, error), + ), + ); + }); }; const withPortField = (field: PortField, error: PortAllocationError): PortAllocationError => @@ -256,90 +586,136 @@ const withPortField = (field: PortField, error: PortAllocationError): PortAlloca detail: error.detail, cause: error.cause, field, + reason: error.reason, ...(error.port === undefined ? {} : { port: error.port }), }); -export const allocatePortSet = ( - requests: ReadonlyArray, - options: PortAllocationOptions = {}, -): Effect.Effect => - Effect.gen(function* () { - const reserved = options.reserved ?? new Set(); - const probe = options.probe ?? defaultPortProbe; - const allocated = new Set(); - const partial: Partial> = {}; - - for (const request of uniqueFields(requests)) { - const exclude = new Set([...reserved, ...allocated]); - const port = yield* resolveSelection(request.selection, exclude, probe).pipe( - Effect.mapError((error) => withPortField(request.field, error)), - ); - allocated.add(port); - partial[request.field] = port; - } - - return Schema.decodeUnknownSync(PortSetSchema)(partial); - }); - export const reservePortSet = ( requests: ReadonlyArray, - options: PortAllocationOptions = {}, -): Effect.Effect => + options: PortSelectionOptions = {}, +): Effect.Effect => Effect.suspend(() => { const reservations = new Map(); - const reserve = Effect.gen(function* () { - const reserved = options.reserved ?? new Set(); - const allocated = new Set(); - const partial: Partial> = {}; - - const bindAndRegister = ( - field: PortField, - acquisition: Effect.Effect, - ) => - Effect.uninterruptibleMask(() => - Effect.gen(function* () { - const result = yield* acquisition.pipe( - Effect.mapError((error) => withPortField(field, error)), - ); - reservations.set(field, result.server); - yield* options.onBound?.(field, result) ?? Effect.void; - return result; - }), - ); + const claims = new Map(); + const root = CLAIM_ROOT; + const reserve = (fs: FileSystem.FileSystem) => + Effect.gen(function* () { + const reserved = options.reserved ?? new Set(); + const allocated = new Set(); + const partial: Partial> = {}; + + const bindAndRegister = ( + field: PortField, + acquisition: Effect.Effect, + ) => + Effect.uninterruptibleMask(() => + Effect.gen(function* () { + const result = yield* acquisition.pipe( + Effect.mapError((error) => + error instanceof PortClaimCollisionError + ? new PortAllocationError({ + detail: `Port ${error.port} is not available`, + field, + port: error.port, + reason: "unavailable", + }) + : withPortField(field, error), + ), + ); + reservations.set(field, result.server); + return result; + }), + ); - for (const request of uniqueFields(requests)) { - const exclude = new Set([...reserved, ...allocated]); - const selection = request.selection; - let bound: BoundPort; - - if (selection.kind === "exact") { - if (exclude.has(selection.port)) { - return yield* new PortAllocationError({ - detail: `Port ${selection.port} is not available`, - field: request.field, - port: selection.port, - }); + for (const request of uniqueFields(requests)) { + const selection = request.selection; + const exclude = new Set([ + ...reserved, + ...allocated, + ...(selection.kind === "automatic" ? (selection.excluded ?? []) : []), + ]); + let bound: BoundPort; + + if (selection.kind === "exact") { + if ( + !Number.isInteger(selection.port) || + selection.port < 1 || + selection.port > 65_535 + ) { + return yield* new PortAllocationError({ + detail: `Invalid exact port ${selection.port}`, + field: request.field, + port: selection.port, + }); + } + if (exclude.has(selection.port)) { + return yield* new PortAllocationError({ + detail: `Port ${selection.port} is not available`, + field: request.field, + port: selection.port, + }); + } + bound = yield* bindAndRegister( + request.field, + claimAndBind(request.field, selection.port, claims, fs, root), + ); + } else if ( + selection.preferred !== undefined && + selection.preferred > 0 && + !exclude.has(selection.preferred) + ) { + const preferred = selection.preferred; + bound = yield* bindAndRegister( + request.field, + claimAndBind(request.field, preferred, claims, fs, root).pipe( + Effect.catchTag("PortClaimCollisionError", () => + reserveRandomPort(exclude, request.field, claims, fs, root).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ), + ), + Effect.catchTag("PortAllocationError", (error) => + error.reason === "unavailable" + ? reserveRandomPort(exclude, request.field, claims, fs, root).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ) + : Effect.fail(error), + ), + ), + ); + } else { + bound = yield* bindAndRegister( + request.field, + reserveRandomPort(exclude, request.field, claims, fs, root).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ), + ); } - bound = yield* bindAndRegister(request.field, bindPort(selection.port)); - } else if (selection.preferred !== undefined && !exclude.has(selection.preferred)) { - bound = yield* bindAndRegister( - request.field, - bindPort(selection.preferred).pipe( - Effect.catchTag("PortAllocationError", () => reserveRandomPort(exclude)), - ), - ); - } else { - bound = yield* bindAndRegister(request.field, reserveRandomPort(exclude)); + + allocated.add(bound.port); + partial[request.field] = bound.port; } - allocated.add(bound.port); - partial[request.field] = bound.port; - } + return makePortLease( + Schema.decodeUnknownSync(PortSetSchema)(partial), + reservations, + claims, + fs, + root, + ); + }); - return makePortLease(Schema.decodeUnknownSync(PortSetSchema)(partial), reservations); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* reserve(fs).pipe( + Effect.onError(() => + Effect.all( + [ + releaseReservations(reservations, [...reservations.keys()]), + releaseClaims(claims, [...claims.keys()], fs), + ], + { discard: true }, + ), + ), + ); }); - - return reserve.pipe( - Effect.onError(() => releaseReservations(reservations, [...reservations.keys()])), - ); }); diff --git a/packages/stack/src/PortAllocator.unit.test.ts b/packages/stack/src/PortAllocator.unit.test.ts deleted file mode 100644 index ae5cea6753..0000000000 --- a/packages/stack/src/PortAllocator.unit.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Cause, Effect, Exit, Schema } from "effect"; -import { PortSetSchema } from "./effect.ts"; -import { allocatePortSet, PortAllocationError } from "./PortAllocator.ts"; -import { DEFAULT_PORTS, type PortField } from "./PortCatalog.ts"; - -const fakePortProbe = ( - options: { - readonly unavailable?: ReadonlySet; - readonly randomPorts?: readonly number[]; - } = {}, -) => { - const unavailable = options.unavailable ?? new Set(); - const randomPorts = - options.randomPorts ?? Array.from({ length: 100 }, (_, index) => 30001 + index); - let randomIndex = 0; - - return { - exact: (port: number) => - unavailable.has(port) - ? Effect.fail(new PortAllocationError({ detail: `Port ${port} is not available`, port })) - : Effect.succeed(port), - random: (exclude: ReadonlySet) => - Effect.gen(function* () { - while (randomIndex < randomPorts.length) { - const port = randomPorts[randomIndex]; - randomIndex += 1; - if (port === undefined) { - continue; - } - if (!exclude.has(port) && !unavailable.has(port)) { - return port; - } - } - - return yield* Effect.fail( - new PortAllocationError({ detail: "No fake random ports available" }), - ); - }), - }; -}; - -describe("allocatePortSet", () => { - const requests = (fields: ReadonlyArray) => - fields.map((field) => ({ field, selection: { kind: "automatic" as const } })); - - it("all allocated ports are unique", async () => { - const ports = await Effect.runPromise( - allocatePortSet( - requests(["apiPort", "dbPort", "authPort", "postgrestPort", "postgrestAdminPort"]), - { probe: fakePortProbe() }, - ), - ); - const values = Object.values(ports) as number[]; - const unique = new Set(values); - expect(unique.size).toBe(values.length); - for (const port of values) { - expect(port).toBeGreaterThan(0); - } - }); - - it("exports the partial allocated port-set schema", () => { - expect(Schema.decodeUnknownSync(PortSetSchema)({ apiPort: 54321 })).toEqual({ - apiPort: 54321, - }); - }); - - it("reserved ports are skipped by later allocations", async () => { - const a = await Effect.runPromise( - allocatePortSet(requests(["apiPort", "dbPort"]), { probe: fakePortProbe() }), - ); - const aPorts = new Set(Object.values(a) as number[]); - const b = await Effect.runPromise( - allocatePortSet(requests(["apiPort", "dbPort"]), { - reserved: aPorts, - probe: fakePortProbe(), - }), - ); - const bPorts = Object.values(b) as number[]; - - for (const port of bPorts) { - expect(aPorts.has(port)).toBe(false); - } - }); - - it("identifies the exact field that collides with an earlier request", async () => { - const exit = await Effect.runPromise( - allocatePortSet( - [ - { field: "apiPort", selection: { kind: "exact", port: 22_001 } }, - { field: "dbPort", selection: { kind: "exact", port: 22_001 } }, - ], - { probe: fakePortProbe() }, - ).pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toMatchObject({ - _tag: "PortAllocationError", - field: "dbPort", - port: 22_001, - }); - } - }); - - it("explicit port is respected when available", async () => { - const requestedApiPort = 21001; - const requestedDbPort = 21002; - const ports = await Effect.runPromise( - allocatePortSet( - [ - { field: "apiPort", selection: { kind: "exact", port: requestedApiPort } }, - { field: "dbPort", selection: { kind: "exact", port: requestedDbPort } }, - ], - { probe: fakePortProbe() }, - ), - ); - expect(ports.apiPort).toBe(requestedApiPort); - expect(ports.dbPort).toBe(requestedDbPort); - }); - - it("preferred ports are reused when available", async () => { - const apiPort = 21003; - const dbPort = 21004; - const studioPort = 21005; - const ports = await Effect.runPromise( - allocatePortSet( - [ - { field: "apiPort", selection: { kind: "automatic", preferred: apiPort } }, - { field: "dbPort", selection: { kind: "automatic", preferred: dbPort } }, - { field: "studioPort", selection: { kind: "automatic", preferred: studioPort } }, - ], - { probe: fakePortProbe() }, - ), - ); - - expect(ports.apiPort).toBe(apiPort); - expect(ports.dbPort).toBe(dbPort); - expect(ports.studioPort).toBe(studioPort); - }); - - it("preferred ports fall back to random ports when unavailable", async () => { - const apiPort = 21006; - const dbPort = 21007; - const ports = await Effect.runPromise( - allocatePortSet( - [ - { field: "apiPort", selection: { kind: "automatic", preferred: apiPort } }, - { field: "dbPort", selection: { kind: "automatic", preferred: dbPort } }, - ], - { - probe: fakePortProbe({ - unavailable: new Set([apiPort]), - randomPorts: Array.from({ length: 20 }, (_, index) => 31001 + index), - }), - }, - ), - ); - - expect(ports.apiPort).toBe(31001); - expect(ports.dbPort).toBe(dbPort); - }); - - it("explicit ports cannot override reserved ownership", async () => { - const exit = await Effect.runPromise( - allocatePortSet([{ field: "apiPort", selection: { kind: "exact", port: 22001 } }], { - reserved: new Set([22001]), - }).pipe(Effect.exit), - ); - - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("Port 22001 is not available"); - } - }); - - it("preferred ports skip reserved ownership and use random fallback", async () => { - const ports = await Effect.runPromise( - allocatePortSet( - [ - { field: "apiPort", selection: { kind: "automatic", preferred: 23001 } }, - { field: "dbPort", selection: { kind: "automatic", preferred: DEFAULT_PORTS.dbPort } }, - ], - { - reserved: new Set([23001]), - probe: fakePortProbe(), - }, - ), - ); - - expect(ports.apiPort).not.toBe(23001); - expect(ports.dbPort).toBe(DEFAULT_PORTS.dbPort); - }); -}); 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..c44944dc02 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 @@ -334,6 +359,9 @@ export const RemoteStack = { Stream.provide(httpTransportClientLayer), Stream.catchTag("HttpTransportClientError", (error) => Stream.die(error)), ); + const withLifecycleRequest = ( + request: (signal: AbortSignal) => Effect.Effect, + ) => withHttpTransportClient(withAbortSignal(request)); return { getInfo: () => @@ -342,10 +370,10 @@ export const RemoteStack = { ), start: () => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const path = "/start"; - const response = yield* httpResponse(endpoint, path, { method: "POST" }); + const response = yield* httpResponse(endpoint, path, { method: "POST", signal }); yield* expectDaemonOk(endpoint, path, response, "stack").pipe( Effect.catchTag("ServiceNotFoundError", (error) => Effect.die(error)), ); @@ -353,10 +381,10 @@ export const RemoteStack = { ), stop: () => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const path = "/stop"; - const response = yield* httpResponse(endpoint, path, { method: "POST" }); + const response = yield* httpResponse(endpoint, path, { method: "POST", signal }); yield* dieOnNonOkStatus( endpoint, path, @@ -366,10 +394,10 @@ export const RemoteStack = { ), dispose: () => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const path = "/stop"; - const response = yield* httpResponse(endpoint, path, { method: "POST" }); + const response = yield* httpResponse(endpoint, path, { method: "POST", signal }); yield* dieOnNonOkStatus( endpoint, path, @@ -379,26 +407,28 @@ export const RemoteStack = { ), startService: (name: string) => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const servicePath = yield* publicServicePath(name); const path = `/services/${servicePath}/start`; const response = yield* httpResponse(endpoint, path, { method: "POST", + signal, }); - yield* expectDaemonOk(endpoint, path, response, name); + yield* expectMutatingDaemonOk(endpoint, path, response, name); }), ), stopService: (name: string) => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const servicePath = yield* publicServicePath(name); const path = `/services/${servicePath}/stop`; const response = yield* httpResponse(endpoint, path, { method: "POST", + signal, }); - 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)), ); @@ -406,40 +436,43 @@ export const RemoteStack = { ), restartService: (name: string) => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const servicePath = yield* publicServicePath(name); const path = `/services/${servicePath}/restart`; const response = yield* httpResponse(endpoint, path, { method: "POST", + signal, }); - yield* expectDaemonOk(endpoint, path, response, name); + yield* expectMutatingDaemonOk(endpoint, path, response, name); }), ), reloadFunctions: (opts) => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const path = "/functions/reload"; const response = yield* httpResponse(endpoint, path, { method: "POST", + signal, headers: { "content-type": "application/json" }, body: JSON.stringify(opts ?? {}), }); - yield* expectDaemonOk(endpoint, path, response, "edge-runtime"); + yield* expectMutatingDaemonOk(endpoint, path, response, "edge-runtime"); }), ), reloadEdgeRuntime: (opts) => - withHttpTransportClient( + withLifecycleRequest((signal) => Effect.gen(function* () { const path = "/edge-runtime/reload"; const response = yield* httpResponse(endpoint, path, { method: "POST", + signal, 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 603a1407de..a53256e731 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -1,13 +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 * as TestClock from "effect/testing/TestClock"; +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"; @@ -52,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, @@ -100,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, @@ -136,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 }; @@ -185,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, @@ -203,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 }], + ]), }; }), }); @@ -212,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) => @@ -223,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" } }); @@ -279,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(); @@ -423,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"], @@ -474,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* () { @@ -487,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(); @@ -519,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)); }); @@ -547,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* () { @@ -561,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( @@ -588,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({ @@ -649,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* () { @@ -663,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")); }); @@ -730,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* () { @@ -776,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* () { @@ -803,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, @@ -827,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; @@ -836,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)); @@ -857,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* () { @@ -879,109 +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.effect("uses the stack readiness deadline for explicit lazy activation and cleans up", () => + it.live("dispose cancels in-flight lazy preparation", () => Effect.gen(function* () { - const authHealthServer = yield* Effect.acquireRelease( - Effect.sync(() => - Bun.serve({ - hostname: "127.0.0.1", - port: 0, - fetch: () => new Response("unhealthy", { status: 503 }), - }), - ), - (server) => Effect.sync(() => server.stop(true)), - ); - const authPort = authHealthServer.port; - if (authPort === undefined) { - throw new Error("Expected the auth health test server to bind a TCP port"); - } - const authConfig = defaultConfig.auth; - if (authConfig === false) { - throw new Error("Expected auth to be enabled in the default test config"); - } - const postgresProbeStarted = yield* Deferred.make(); - const postgresInitStarted = yield* Deferred.make(); - const authSpawnStarted = yield* Deferred.make(); - const spawner = mockChildProcessSpawner({ - beforeSpawn: (record) => { - if (record.command.endsWith("/pg_isready")) { - return Deferred.succeed(postgresProbeStarted, undefined).pipe(Effect.asVoid); - } - if ( - record.args.some((arg) => - Buffer.from(arg, "base64url").toString().includes('"command":"bash","args":["-c"'), - ) - ) { - return Deferred.succeed(postgresInitStarted, undefined).pipe(Effect.asVoid); - } - return record.args.some((arg) => - Buffer.from(arg, "base64url").toString().includes('"command":"/cache/auth/'), - ) - ? Deferred.succeed(authSpawnStarted, undefined).pipe(Effect.asVoid) - : Effect.void; - }, + 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, }); - let releasedAll = false; const config = { ...defaultConfig, - startupMode: "lazy", - ports: { ...defaultPorts, authPort }, - auth: { ...authConfig, port: authPort }, - 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; - const start = yield* stack.start().pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(postgresProbeStarted); - yield* TestClock.adjust("10 millis"); - yield* Deferred.await(postgresInitStarted); - yield* TestClock.adjust("89 millis"); - yield* Fiber.join(start); - - const activation = yield* activator - .activate("auth") + 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 })); - yield* Deferred.await(authSpawnStarted); - yield* TestClock.adjust("100 millis"); - const error = yield* Fiber.join(activation).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), ); @@ -991,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 }) @@ -1029,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); @@ -1039,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"); } @@ -1057,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 }, }); @@ -1085,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; @@ -1100,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* () { @@ -1118,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* () { @@ -1133,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* () { @@ -1167,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..f2d5edc951 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -1,21 +1,26 @@ +import { join } from "node:path"; import { buildGraph } from "@supabase/process-compose"; import type { ResolvedGraph, ServiceDef } from "@supabase/process-compose"; -import { Effect, Layer, Context } from "effect"; +import { Context, Effect, FileSystem, Layer, Scope } from "effect"; import type { CleanupTargets } from "./CleanupTargets.ts"; import { StackBuildError } from "./errors.ts"; import { generateJwks } from "./JwtGenerator.ts"; import { detectPlatform, dockerHostAddress } from "./Platform.ts"; +import { shortTempPrefixRoot } from "./paths.ts"; import { makeAnalyticsServiceDocker } from "./services/analytics.ts"; import { makeAuthServiceDocker, makeAuthServiceNative } from "./services/auth.ts"; import { makeEdgeRuntimeServiceDocker, - makeEdgeRuntimeServiceNative, + prepareEdgeRuntimeBootstrap, } from "./services/edge-runtime.ts"; 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 +53,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 +65,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 +81,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; }; @@ -97,10 +101,64 @@ const hasAutoManagedPath = (config: ResolvedStackConfig, path: string) => const resolvedConfigForService = (config: ResolvedStackConfig, service: ServiceName) => config[serviceMetadata(service).configKey]; +const prepareNativePostgresAlias = ( + preparedPath: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const aliasRoot = yield* Effect.acquireRelease( + fs.makeTempDirectory({ + directory: shortTempPrefixRoot(), + prefix: "supabase-stack-postgres-", + }), + (path) => fs.remove(path, { recursive: true, force: true }).pipe(Effect.ignore), + ).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to create a private native PostgreSQL binary directory", + cause, + }), + ), + ); + const aliasPath = join(aliasRoot, "bundle"); + if (/\s/.test(aliasPath) || (process.platform !== "darwin" && process.platform !== "linux")) { + yield* fs.remove(aliasRoot, { recursive: true, force: true }).pipe(Effect.ignore); + return yield* Effect.fail( + new StackBuildError({ + detail: "Native PostgreSQL requires a Unix temporary path without whitespace", + reason: "invalid_config", + }), + ); + } + + yield* fs.symlink(preparedPath, aliasPath).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to publish the native PostgreSQL binary alias", + cause, + }), + ), + ); + return aliasPath; + }); + 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 +175,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 +212,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,18 +258,13 @@ const requirePreparedDockerImage = ( ), ); -export const nativePostgresNeedsDockerAccess = ( - postgresResolution: ServiceResolution, - dockerServicesEnabled: boolean, -): boolean => postgresResolution.type === "binary" && dockerServicesEnabled; - export class StackBuilder extends Context.Service< StackBuilder, { readonly build: ( config: ResolvedStackConfig, prepared: PreparedStackArtifacts, - ) => Effect.Effect; + ) => Effect.Effect; } >()("local/StackBuilder") { static layer: Layer.Layer = Layer.succeed(this, { @@ -217,6 +272,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; @@ -226,39 +292,12 @@ export class StackBuilder extends Context.Service< const authResolution = config.auth === false ? false : yield* requirePreparedResolution(prepared, "auth"); - const edgeRuntimeResolution = - config.edgeRuntime === false - ? false - : yield* requirePreparedResolution(prepared, "edge-runtime"); - const postgrestResolution = config.postgrest === false ? 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 = @@ -270,44 +309,53 @@ export class StackBuilder extends Context.Service< const jwtJwks = generateJwks(config.jwtSecret); const identity = stackIdentity(config); + const postgresService = + postgresResolution.type === "binary" + ? makePostgresService({ + binPath: yield* prepareNativePostgresAlias(postgresResolution.path), + dataDir: config.postgres.dataDir, + port: config.dbPort, + cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), + dependencies: [], + }) + : makePostgresServiceDocker({ + runtime: yield* requireContainerRuntime, + image: postgresResolution.image, + dataDir: config.postgres.dataDir, + port: config.dbPort, + platformOs: platform.os, + identity, + cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), + dependencies: [], + }); + const defs: Array = [ { - ...(postgresResolution.type === "binary" - ? makePostgresService({ - binPath: postgresResolution.path, - dataDir: config.postgres.dataDir, - port: config.dbPort, - dockerAccessible: needsDockerAccess, - cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), - dependencies: [], - }) - : makePostgresServiceDocker({ - 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: [], - })), + ...postgresService, enabled: true, }, ]; - 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 +368,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 +383,7 @@ export class StackBuilder extends Context.Service< jwtSecret: config.jwtSecret, platformOs: platform.os, identity, - dependencies: postgresDeps, + dependencies: postgresDependencies, })), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, @@ -356,9 +405,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,37 +423,31 @@ 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, }); } - if (config.edgeRuntime !== false && edgeRuntimeResolution !== false) { + if (config.edgeRuntime !== false) { + const edgeRuntimeImage = yield* requirePreparedDockerImage(prepared, "edge-runtime"); + const edgeRuntimeBootstrapDir = yield* prepareEdgeRuntimeBootstrap(config.runtimeRoot); defs.push({ - ...(edgeRuntimeResolution.type === "binary" - ? makeEdgeRuntimeServiceNative({ - binPath: edgeRuntimeResolution.path, - runtimeRoot: config.runtimeRoot, - port: config.edgeRuntime.port, - inspectorPort: config.edgeRuntime.inspectorPort, - policy: config.edgeRuntime.policy, - env: config.edgeRuntime.env, - dependencies: postgresDeps, - }) - : makeEdgeRuntimeServiceDocker({ - image: edgeRuntimeResolution.image, - identity, - runtimeRoot: config.runtimeRoot, - projectDir, - port: config.edgeRuntime.port, - inspectorPort: config.edgeRuntime.inspectorPort, - policy: config.edgeRuntime.policy, - env: config.edgeRuntime.env, - platformOs: platform.os, - dependencies: postgresDeps, - })), + ...makeEdgeRuntimeServiceDocker({ + runtime: yield* requireContainerRuntime, + image: edgeRuntimeImage, + identity, + runtimeRoot: config.runtimeRoot, + bootstrapDir: edgeRuntimeBootstrapDir, + projectDir, + port: config.edgeRuntime.port, + inspectorPort: config.edgeRuntime.inspectorPort, + policy: config.edgeRuntime.policy, + env: config.edgeRuntime.env, + platformOs: platform.os, + dependencies: postgresDependencies, + }), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, }); @@ -413,6 +457,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 +474,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 +487,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 +498,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 +515,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 +528,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 +545,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 +563,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 +572,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 +584,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 +602,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 +617,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 +629,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 +661,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 +684,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..0d186b83da 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Layer, Sink, Stream } from "effect"; +import { NodeFileSystem } from "@effect/platform-node"; +import { Deferred, Effect, FileSystem, Layer, Scope, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; import { defaultPublishableKey, defaultSecretKey, generateJwt } from "./JwtGenerator.ts"; @@ -9,7 +10,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 +47,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 +112,7 @@ const baseConfig: ResolvedStackConfig = { const dockerConfig: ResolvedStackConfig = { ...baseConfig, mode: "docker", + containerRuntime: "docker", }; /** @@ -119,7 +135,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, @@ -171,6 +189,7 @@ function builderLayer( ) { return Layer.mergeAll( StackBuilder.layer, + NodeFileSystem.layer, StackPreparation.layer.pipe(Layer.provide(resolver.layer), Layer.provide(spawnerLayer)), ); } @@ -179,30 +198,30 @@ const prepareAndBuild = ( builder: typeof StackBuilder.Service, preparation: typeof StackPreparation.Service, config: ResolvedStackConfig, -): Effect.Effect => +): 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); + const fs = yield* FileSystem.FileSystem; + const scope = yield* Effect.scope; + return yield* builder + .build(config, prepared) + .pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Scope.Scope, scope), + ); }); 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 +274,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 +296,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 +319,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 +358,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 +416,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 +433,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 +450,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 +483,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..ff6fef6a8c --- /dev/null +++ b/packages/stack/src/StackConfigResolver.policy.unit.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import { NodeFileSystem } from "@effect/platform-node"; +import { Cause, Effect, Exit, FileSystem } from "effect"; +import { systemError } from "effect/PlatformError"; +import { + resolveConfig as resolveConfigEffect, + type ResolveConfigOptions, +} from "./StackConfigResolver.ts"; +import { StackBuildError } from "./errors.ts"; +import type { PortSet } from "./PortCatalog.ts"; + +const testPorts: PortSet = { + apiPort: 40_000, + dbPort: 40_001, + authPort: 40_002, + postgrestPort: 40_003, + postgrestAdminPort: 40_004, + realtimePort: 40_005, + storagePort: 40_006, + imgproxyPort: 40_007, + mailpitPort: 40_008, + mailpitSmtpPort: 40_009, + mailpitPop3Port: 40_010, + pgmetaPort: 40_011, + studioPort: 40_012, + analyticsPort: 40_013, + poolerPort: 40_014, + poolerApiPort: 40_015, +}; + +const resolveConfig = ( + config?: Parameters[0], + options?: Partial, +) => + Effect.runPromise( + resolveConfigEffect(config, { ...options, ports: options?.ports ?? testPorts }).pipe( + Effect.provide(NodeFileSystem.layer), + ), + ); + +describe("resolved service preparation policies", () => { + it("maps temporary-root filesystem failures to StackBuildError", async () => { + const exit = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const failingFs = { + ...fs, + makeTempDirectory: () => + Effect.fail( + systemError({ + _tag: "PermissionDenied", + module: "test", + method: "makeTempDirectory", + }), + ), + }; + return yield* resolveConfigEffect(undefined, { ports: testPorts }).pipe( + Effect.provideService(FileSystem.FileSystem, failingFs), + Effect.exit, + ); + }).pipe(Effect.provide(NodeFileSystem.layer)), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.findErrorOption(exit.cause)).toMatchObject({ + _tag: "Some", + value: { _tag: "StackBuildError" }, + }); + } + }); + + it("rejects a caller lease that disagrees with an explicit port before creating roots", async () => { + let tempRootAttempts = 0; + const exit = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const failingFs = { + ...fs, + makeTempDirectory: () => + Effect.sync(() => { + tempRootAttempts += 1; + }).pipe( + Effect.andThen( + Effect.fail( + systemError({ + _tag: "Unknown", + module: "test", + method: "makeTempDirectory", + description: "root creation must not run", + }), + ), + ), + ), + }; + return yield* resolveConfigEffect( + { port: 45_123 }, + { ports: { ...testPorts, apiPort: 45_122 } }, + ).pipe(Effect.provideService(FileSystem.FileSystem, failingFs), Effect.exit); + }).pipe(Effect.provide(NodeFileSystem.layer)), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.findErrorOption(exit.cause)).toMatchObject({ + _tag: "Some", + value: { _tag: "StackBuildError", reason: "invalid_config" }, + }); + } + expect(tempRootAttempts).toBe(0); + }); + + 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 () => { + await expect(resolveConfig({ servicePolicies: { postgres: "lazy" } })).rejects.toBeInstanceOf( + StackBuildError, + ); + }); + + 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 () => { + await expect( + resolveConfig({ + analytics: {}, + vector: {}, + servicePolicies: { analytics: "lazy", vector: "eager" }, + }), + ).rejects.toMatchObject({ + _tag: "StackBuildError", + reason: "invalid_config", + }); + }); +}); diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index 9aa29921a9..54abb34cf3 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -1,8 +1,11 @@ -import { mkdtempSync } from "node:fs"; import { join } from "node:path"; -import { Effect, Schema } from "effect"; -import { StackBuildError, toStackError } from "./errors.ts"; -import { resolvedFunctionsBundleSchemaForProject } from "./functions.ts"; +import { Effect, Exit, FileSystem, Record, Schema } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import { StackBuildError } from "./errors.ts"; +import { + resolvedFunctionsBundleSchemaForProject, + type ResolvedFunctionsBundle, +} from "./functions.ts"; import { defaultJwtSecret, defaultPublishableKey, @@ -10,14 +13,9 @@ import { generateJwt, } from "./JwtGenerator.ts"; import { defaultCacheRoot, shortTempPrefixRoot } from "./paths.ts"; -import { - allocatePortSet, - type PortReservationRequest, - type PortAllocationError, - type PortSelectionOptions, -} from "./PortAllocator.ts"; +import { type PortReservationRequest } 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,22 +40,29 @@ 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 { + /** Ports selected by the caller-owned lease. Resolution never allocates ports. */ + readonly ports: PortSet; readonly stackRoot?: string; readonly runtimeRoot?: string; - readonly preferredPorts?: PortSet; - readonly reservedPorts?: ReadonlySet; - readonly portAllocator?: ( - requests: ReadonlyArray, - options: PortSelectionOptions, - ) => Effect.Effect; + readonly runtime?: StackRuntimeSelection; } interface ResolvedRoots { @@ -67,37 +72,68 @@ interface ResolvedRoots { readonly autoManagedPaths: ReadonlyArray; } -const makeTempRoot = (prefix: string) => mkdtempSync(join(shortTempPrefixRoot(), prefix)); - -const resolveRoots = (config: StackConfig, opts: ResolveConfigOptions): ResolvedRoots => { - const cacheRoot = config.cacheRoot ?? defaultCacheRoot(); - const autoManagedPaths: string[] = []; - - const stackRoot = - opts.stackRoot ?? - config.stackRoot ?? - (() => { - const dir = makeTempRoot("sb-stack-"); - autoManagedPaths.push(dir); - return dir; - })(); - - const runtimeRoot = - opts.runtimeRoot ?? - config.runtimeRoot ?? - (() => { - const dir = makeTempRoot("sb-run-"); - autoManagedPaths.push(dir); - return dir; - })(); +const cleanupAutoManagedPaths = ( + paths: ReadonlyArray, +): Effect.Effect => + Effect.uninterruptible( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* Effect.forEach( + paths, + (path) => fs.remove(path, { recursive: true, force: true }).pipe(Effect.ignoreCause), + { discard: true }, + ); + }), + ); + +const tempRootError = (prefix: string, cause: PlatformError): StackBuildError => + new StackBuildError({ + detail: `Failed to create temporary ${prefix} directory`, + cause, + }); - return { - cacheRoot, - stackRoot, - runtimeRoot, - autoManagedPaths, - }; -}; +const makeTempRoot = ( + prefix: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs + .makeTempDirectory({ directory: shortTempPrefixRoot(), prefix }) + .pipe(Effect.mapError((cause) => tempRootError(prefix, cause))); + }); + +const resolveRoots = ( + config: StackConfig, + opts: ResolveConfigOptions, +): Effect.Effect => + Effect.gen(function* () { + const cacheRoot = config.cacheRoot ?? defaultCacheRoot(); + const autoManagedPaths: string[] = []; + const roots = yield* Effect.gen(function* () { + const makeTrackedTempRoot = (prefix: string) => + Effect.uninterruptibleMask((restore) => + restore(makeTempRoot(prefix)).pipe( + Effect.tap((dir) => Effect.sync(() => autoManagedPaths.push(dir))), + ), + ); + const stackRoot = + opts.stackRoot ?? config.stackRoot ?? (yield* makeTrackedTempRoot("sb-stack-")); + const runtimeRoot = + opts.runtimeRoot ?? config.runtimeRoot ?? (yield* makeTrackedTempRoot("sb-run-")); + + return { + cacheRoot, + stackRoot, + runtimeRoot, + autoManagedPaths, + }; + }).pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) ? Effect.void : cleanupAutoManagedPaths(autoManagedPaths), + ), + ); + return roots; + }); const resolveDataDir = ( explicitDir: string | undefined, @@ -185,37 +221,41 @@ function resolveEdgeRuntimeConfig( }; } -async function resolveFunctionsConfig(config: StackConfig, projectDir: string) { +function resolveFunctionsConfig( + config: StackConfig, + projectDir: string, +): Effect.Effect { if (config.functions === undefined || config.functions === false) { - return false; - } - try { - return await Schema.decodeUnknownPromise(resolvedFunctionsBundleSchemaForProject(projectDir))( - config.functions, - ); - } catch (cause) { - throw new StackBuildError({ - detail: "Invalid Edge Functions bundle", - cause, - reason: "invalid_config", - }); + return Effect.succeed(false); } + return Schema.decodeUnknownEffect(resolvedFunctionsBundleSchemaForProject(projectDir))( + config.functions, + ).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Invalid Edge Functions bundle", + cause, + reason: "invalid_config", + }), + ), + ); } -function resolveInstanceId(instanceId: string | undefined): string | undefined { - if (instanceId === undefined) { - return undefined; - } - try { - return Schema.decodeUnknownSync(InstanceIdSchema)(instanceId); - } catch (cause) { - throw new StackBuildError({ - detail: `Invalid instanceId: must match ${INSTANCE_ID_PATTERN}`, - cause, - reason: "invalid_config", - }); - } -} +const resolveInstanceId = ( + instanceId: string | undefined, +): Effect.Effect => + instanceId === undefined + ? Effect.succeed(undefined) + : Effect.try({ + try: () => Schema.decodeUnknownSync(InstanceIdSchema)(instanceId), + catch: (cause) => + new StackBuildError({ + detail: `Invalid instanceId: must match ${INSTANCE_ID_PATTERN}`, + cause, + reason: "invalid_config", + }), + }); function resolveStorageConfig( input: StorageConfig | undefined, @@ -344,164 +384,404 @@ const enabledServiceConfig = ( config: Config | false | undefined, ): Config | undefined => (enabled && config !== false ? config : undefined); -export async function resolveConfig( - input?: StackConfig, - opts: ResolveConfigOptions = {}, -): Promise { - const config = input ?? {}; - 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 edgeRuntimeInput = enabledServiceConfig(edgeRuntimeEnabled, config.edgeRuntime); - const realtimeInput = enabledServiceConfig(realtimeEnabled, config.realtime); - const storageInput = enabledServiceConfig(storageEnabled, config.storage); - const imgproxyInput = enabledServiceConfig(imgproxyEnabled, config.imgproxy); - const mailpitInput = enabledServiceConfig(mailpitEnabled, config.mailpit); - const pgmetaInput = enabledServiceConfig(pgmetaEnabled, config.pgmeta); - const studioInput = enabledServiceConfig(studioEnabled, config.studio); - const analyticsInput = enabledServiceConfig(analyticsEnabled, config.analytics); - const vectorInput = enabledServiceConfig(vectorEnabled, config.vector); - const poolerInput = enabledServiceConfig(poolerEnabled, config.pooler); - - const postgresDataDir = resolveDataDir(postgresInput.dataDir, roots.stackRoot, "postgres"); - - const explicitPortForField = (field: PortField): number | undefined => { - switch (field) { - case "apiPort": - return config.port; - case "dbPort": - return postgresInput.port; - case "authPort": - return authInput?.port; - case "edgeRuntimePort": - return edgeRuntimeInput?.port; - case "edgeRuntimeInspectorPort": - return edgeRuntimeInput?.inspectorPort; - case "realtimePort": - return realtimeInput?.port; - case "storagePort": - return storageInput?.port; - case "imgproxyPort": - return imgproxyInput?.port; - case "mailpitPort": - return mailpitInput?.port; - case "mailpitSmtpPort": - return mailpitInput?.smtpPort; - case "mailpitPop3Port": - return mailpitInput?.pop3Port; - case "pgmetaPort": - return pgmetaInput?.port; - case "studioPort": - return studioInput?.port; - case "analyticsPort": - return analyticsInput?.port; - case "poolerPort": - return poolerInput?.port; - case "poolerApiPort": - return poolerInput?.apiPort; - case "postgrestPort": - case "postgrestAdminPort": - return 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, +): Effect.Effect => + Effect.gen(function* () { + 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") { + return yield* Effect.fail( + 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") { + return yield* Effect.fail( + 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)) { + return yield* Effect.fail( + new StackBuildError({ + detail: `${service} does not support the ${policy} service preparation policy`, + reason: "invalid_config", + }), + ); + } + policies[service] = policy; } - }; - const unorderedRequests: ReadonlyArray = portFieldsForConfigInput( - config, - ).map((field) => { - const explicit = explicitPortForField(field); - if (explicit !== undefined) { - return { field, selection: { kind: "exact", port: explicit } }; + 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) { + return yield* Effect.fail( + 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; + } + } } - const preferred = opts.preferredPorts?.[field] ?? PORT_CATALOG[field].preferred; - return preferred === undefined - ? { field, selection: { kind: "automatic" } } - : { field, selection: { kind: "automatic", preferred } }; - }); - const requests: ReadonlyArray = [ - ...unorderedRequests.filter((request) => request.selection.kind === "exact"), - ...unorderedRequests.filter((request) => request.selection.kind === "automatic"), - ]; - - const ports = await Effect.runPromise( - (opts.portAllocator ?? allocatePortSet)(requests, { reserved: opts.reservedPorts }), - ).catch((error: unknown) => { - throw toStackError(error); + return policies; }); - const jwtSecret = config.jwtSecret ?? defaultJwtSecret; - const anonJwt = generateJwt(jwtSecret, "anon"); - const serviceRoleJwt = generateJwt(jwtSecret, "service_role"); - const apiPort = requiredPort(ports, "apiPort"); - const dbPort = requiredPort(ports, "dbPort"); - const resolvedPorts: ResolvedPorts = { ...ports, apiPort, dbPort }; +export interface PortRequestOptions { + readonly preferredPorts?: PortSet; + readonly runtime?: StackRuntimeSelection; +} - return { - instanceId, - cacheRoot: roots.cacheRoot, - stackRoot: roots.stackRoot, - runtimeRoot: roots.runtimeRoot, - projectDir, - mode: resolvedMode, - startupMode: config.startupMode ?? "eager", - readiness: resolveReadinessPolicy({ stackPolicy: config.readiness }), - readinessSource: config.readiness === undefined ? "default" : "configured", - jwtSecret, - ports: resolvedPorts, - apiPort, - dbPort, - publishableKey: config.publishableKey ?? defaultPublishableKey, - secretKey: config.secretKey ?? defaultSecretKey, - functions, - autoManagedPaths: roots.autoManagedPaths, - anonJwt, - serviceRoleJwt, - postgres: { - port: dbPort, - dataDir: postgresDataDir, - version: postgresInput.version ?? DEFAULT_VERSIONS.postgres, - autoExposeNewTables: postgresInput.autoExposeNewTables ?? true, - }, - postgrest: resolvePostgrestConfig(postgrestInput, config.postgrest, ports), - auth: resolveAuthConfig(authInput, config.auth, ports, apiPort), - edgeRuntime: edgeRuntimeEnabled - ? resolveEdgeRuntimeConfig(edgeRuntimeInput, config.edgeRuntime, ports) - : false, - realtime: realtimeEnabled - ? resolveRealtimeConfig(realtimeInput, config.realtime, ports) - : false, - storage: storageEnabled - ? resolveStorageConfig(storageInput, config.storage, ports, { - ...opts, - stackRoot: roots.stackRoot, - }) - : false, - imgproxy: imgproxyEnabled - ? resolveImgproxyConfig(imgproxyInput, config.imgproxy, ports) - : false, - mailpit: mailpitEnabled ? resolveMailpitConfig(mailpitInput, config.mailpit, ports) : false, - pgmeta: pgmetaEnabled ? resolvePgmetaConfig(pgmetaInput, config.pgmeta, ports) : false, - studio: studioEnabled ? resolveStudioConfig(studioInput, config.studio, ports, apiPort) : false, - analytics: analyticsEnabled - ? resolveAnalyticsConfig(analyticsInput, config.analytics, ports) - : false, - vector: vectorEnabled ? resolveVectorConfig(vectorInput, config.vector) : false, - pooler: poolerEnabled ? resolvePoolerConfig(poolerInput, config.pooler, ports) : false, - }; +/** + * Validate the allocation-relevant parts of a stack configuration and return + * exact requests before automatic requests. The helper is allocation-free; + * callers reserve the returned requests and pass the resulting ports into + * `resolveConfig`. + */ +export const portRequestsForConfig = ( + input: StackConfig = {}, + options: PortRequestOptions = {}, +): Effect.Effect, StackBuildError> => + Effect.gen(function* () { + if ( + input.mode !== undefined && + options.runtime !== undefined && + input.mode !== options.runtime.mode + ) { + return yield* Effect.fail( + new StackBuildError({ + detail: `Selected ${options.runtime.mode} runtime does not match requested ${input.mode} mode`, + reason: "invalid_config", + }), + ); + } + const mode = options.runtime?.mode ?? input.mode ?? "native"; + const config: StackConfig = { ...input, mode }; + if (mode === "docker" && options.runtime?.containerRuntime == null) { + return yield* Effect.fail( + new StackBuildError({ + detail: "Docker mode requires a selected Docker or Podman runtime", + reason: "invalid_config", + }), + ); + } + + // Deliberately first: unsupported policies and invalid explicit ports must + // fail before a caller acquires any OS resource. + yield* resolveServicePolicies(config); + const postgresInput = config.postgres ?? {}; + const authInput = config.auth !== false ? (config.auth ?? undefined) : undefined; + const edgeRuntimeInput = config.edgeRuntime !== false ? config.edgeRuntime : undefined; + const realtimeInput = config.realtime !== false ? (config.realtime ?? undefined) : undefined; + const storageInput = config.storage !== false ? (config.storage ?? undefined) : undefined; + const imgproxyInput = config.imgproxy !== false ? (config.imgproxy ?? undefined) : undefined; + const mailpitInput = config.mailpit !== false ? (config.mailpit ?? undefined) : undefined; + const pgmetaInput = config.pgmeta !== false ? (config.pgmeta ?? undefined) : undefined; + const studioInput = config.studio !== false ? (config.studio ?? undefined) : undefined; + const analyticsInput = config.analytics !== false ? (config.analytics ?? undefined) : undefined; + const poolerInput = config.pooler !== false ? (config.pooler ?? undefined) : undefined; + const explicitPortForField = (field: PortField): number | undefined => { + switch (field) { + case "apiPort": + return config.port; + case "dbPort": + return postgresInput.port; + case "authPort": + return authInput?.port; + case "edgeRuntimePort": + return edgeRuntimeInput?.port; + case "edgeRuntimeInspectorPort": + return edgeRuntimeInput?.inspectorPort; + case "realtimePort": + return realtimeInput?.port; + case "storagePort": + return storageInput?.port; + case "imgproxyPort": + return imgproxyInput?.port; + case "mailpitPort": + return mailpitInput?.port; + case "mailpitSmtpPort": + return mailpitInput?.smtpPort; + case "mailpitPop3Port": + return mailpitInput?.pop3Port; + case "pgmetaPort": + return pgmetaInput?.port; + case "studioPort": + return studioInput?.port; + case "analyticsPort": + return analyticsInput?.port; + case "poolerPort": + return poolerInput?.port; + case "poolerApiPort": + return poolerInput?.apiPort; + case "postgrestPort": + case "postgrestAdminPort": + return undefined; + } + }; + const activeFields = portFieldsForConfigInput(config); + for (const field of activeFields) { + const explicit = explicitPortForField(field); + if ( + explicit !== undefined && + (!Number.isInteger(explicit) || explicit < 1 || explicit > 65_535) + ) { + return yield* Effect.fail( + new StackBuildError({ + detail: `Invalid port for ${field}: expected an integer between 1 and 65535`, + reason: "invalid_config", + }), + ); + } + } + const unorderedRequests = activeFields.map((field) => { + const explicit = explicitPortForField(field); + if (explicit !== undefined) { + return { field, selection: { kind: "exact", port: explicit } } as const; + } + const preferred = options.preferredPorts?.[field] ?? PORT_CATALOG[field].preferred; + return preferred === undefined + ? ({ field, selection: { kind: "automatic" } } as const) + : ({ field, selection: { kind: "automatic", preferred } } as const); + }); + return [ + ...unorderedRequests.filter((request) => request.selection.kind === "exact"), + ...unorderedRequests.filter((request) => request.selection.kind === "automatic"), + ]; + }); + +export function resolveConfig( + input: StackConfig | undefined, + opts: ResolveConfigOptions, +): Effect.Effect { + return Effect.suspend(() => { + let roots: ResolvedRoots | undefined; + const cleanup = () => + roots === undefined ? Effect.void : cleanupAutoManagedPaths(roots.autoManagedPaths); + + return Effect.gen(function* () { + const inputConfig = input ?? {}; + const resolvedMode = opts.runtime?.mode ?? inputConfig.mode ?? "native"; + const containerRuntime = opts.runtime?.containerRuntime ?? null; + const config: StackConfig = { ...inputConfig, mode: resolvedMode }; + // Deliberately first: unsupported policies must not create roots or reserve ports. + const requests = yield* portRequestsForConfig(inputConfig, { runtime: opts.runtime }); + for (const request of requests) { + if (request.selection.kind !== "exact") continue; + const resolvedPort = opts.ports[request.field]; + if (resolvedPort === request.selection.port) continue; + return yield* Effect.fail( + new StackBuildError({ + detail: `Resolved port for ${request.field} does not match explicit configuration`, + reason: "invalid_config", + }), + ); + } + const servicePolicies = yield* resolveServicePolicies(config); + for (const field of portFieldsForConfigInput(config)) { + if (opts.ports[field] === undefined) { + return yield* Effect.fail( + new StackBuildError({ + detail: `Missing resolved port for active field ${field}`, + reason: "invalid_config", + }), + ); + } + } + const projectDir = config.projectDir ?? process.cwd(); + const instanceId = yield* resolveInstanceId(config.instanceId); + const functions = yield* resolveFunctionsConfig(config, projectDir); + roots = yield* resolveRoots(config, opts); + const postgresInput = config.postgres ?? {}; + 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); + const imgproxyInput = enabledServiceConfig(imgproxyEnabled, config.imgproxy); + const mailpitInput = enabledServiceConfig(mailpitEnabled, config.mailpit); + const pgmetaInput = enabledServiceConfig(pgmetaEnabled, config.pgmeta); + const studioInput = enabledServiceConfig(studioEnabled, config.studio); + const analyticsInput = enabledServiceConfig(analyticsEnabled, config.analytics); + const vectorInput = enabledServiceConfig(vectorEnabled, config.vector); + const poolerInput = enabledServiceConfig(poolerEnabled, config.pooler); + + const postgresDataDir = resolveDataDir(postgresInput.dataDir, roots.stackRoot, "postgres"); + + // Port selection is owned by the caller. Resolve the provided lease result only. + const ports = opts.ports; + + const jwtSecret = config.jwtSecret ?? defaultJwtSecret; + const anonJwt = generateJwt(jwtSecret, "anon"); + const serviceRoleJwt = generateJwt(jwtSecret, "service_role"); + const apiPort = requiredPort(ports, "apiPort"); + const dbPort = requiredPort(ports, "dbPort"); + const resolvedPorts: ResolvedPorts = { ...ports, apiPort, dbPort }; + + return { + instanceId, + cacheRoot: roots.cacheRoot, + stackRoot: roots.stackRoot, + runtimeRoot: roots.runtimeRoot, + projectDir, + mode: resolvedMode, + containerRuntime, + servicePolicies, + readiness: resolveReadinessPolicy({ stackPolicy: config.readiness }), + readinessSource: + config.readiness === undefined ? ("default" as const) : ("configured" as const), + jwtSecret, + ports: resolvedPorts, + apiPort, + dbPort, + publishableKey: config.publishableKey ?? defaultPublishableKey, + secretKey: config.secretKey ?? defaultSecretKey, + functions, + autoManagedPaths: roots.autoManagedPaths, + anonJwt, + serviceRoleJwt, + postgres: { + port: dbPort, + dataDir: postgresDataDir, + version: postgresInput.version ?? DEFAULT_VERSIONS.postgres, + autoExposeNewTables: postgresInput.autoExposeNewTables ?? true, + }, + 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, + realtime: realtimeEnabled + ? resolveRealtimeConfig(realtimeInput, config.realtime, ports) + : false, + storage: storageEnabled + ? resolveStorageConfig(storageInput, config.storage, ports, { + ...opts, + stackRoot: roots.stackRoot, + }) + : false, + imgproxy: imgproxyEnabled + ? resolveImgproxyConfig(imgproxyInput, config.imgproxy, ports) + : false, + mailpit: mailpitEnabled ? resolveMailpitConfig(mailpitInput, config.mailpit, ports) : false, + pgmeta: pgmetaEnabled ? resolvePgmetaConfig(pgmetaInput, config.pgmeta, ports) : false, + studio: studioEnabled + ? resolveStudioConfig(studioInput, config.studio, ports, apiPort) + : false, + analytics: analyticsEnabled + ? resolveAnalyticsConfig(analyticsInput, config.analytics, ports) + : false, + vector: vectorEnabled ? resolveVectorConfig(vectorInput, config.vector) : false, + pooler: poolerEnabled ? resolvePoolerConfig(poolerInput, config.pooler, ports) : false, + }; + }).pipe( + Effect.catchDefect((cause) => + cause instanceof StackBuildError ? Effect.fail(cause) : Effect.die(cause), + ), + Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : cleanup())), + ); + }); } export type DaemonConfigInput = Omit & { diff --git a/packages/stack/src/StackPreparation.ts b/packages/stack/src/StackPreparation.ts index 50761ed59e..dca7d5b135 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"; @@ -20,12 +39,27 @@ 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 +68,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 +89,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 PreparedStackArtifacts; }); 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 +201,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 +319,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..9a5da207e0 100644 --- a/packages/stack/src/bun.ts +++ b/packages/stack/src/bun.ts @@ -2,9 +2,13 @@ import { BunServices } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { BinaryResolver } from "./BinaryResolver.ts"; -import { createStack as createStackCore, type StackHandle } from "./createStack.ts"; +import { selectStackRuntime } from "./ContainerRuntime.ts"; +import { createStack as createStackCore, type ResolveConfigEffect } from "./createStack.ts"; +import { toStackHandle, type StackHandle } from "./stackHandle.ts"; +import { toStackError } from "./errors.ts"; import { prefetch as prefetchEffect, + type PrefetchEffectOptions, type PrefetchOptions, type PrefetchResult, } from "./prefetch.ts"; @@ -12,18 +16,41 @@ import { defaultCacheRoot } from "./paths.ts"; import { platformFactory } from "./platform-bun.ts"; import { StackPreparation } from "./StackPreparation.ts"; import type { StackConfig } from "./StackConfig.ts"; +import { resolveConfig as resolveConfigEffect } from "./StackConfigResolver.ts"; + +const resolveConfigEffectForPlatform: ResolveConfigEffect = (config, options) => + resolveConfigEffect(config, options); 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); + }); + const handle = await Effect.runPromise( + createStackCore(config, platformFactory, runtime, resolveConfigEffectForPlatform).pipe( + Effect.provide(BunServices.layer), + ), + ); + return toStackHandle(handle); } 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..bd49bd2deb 100644 --- a/packages/stack/src/createStack.integration.test.ts +++ b/packages/stack/src/createStack.integration.test.ts @@ -1,98 +1,238 @@ +import { createServer } from "node:net"; +import { existsSync } from "node:fs"; import { afterEach, describe, expect, it } from "vitest"; -import { Effect } from "effect"; -import { createStack, type StackHandle } from "./createStack.ts"; -import { reservePortSet } from "./PortAllocator.ts"; +import { NodeFileSystem, NodeServices } from "@effect/platform-node"; +import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Layer, Option } from "effect"; +import { systemError } from "effect/PlatformError"; +import { + createStack, + type ForegroundStackHandle, + type PlatformFactory, + type ResolveConfigEffect, +} from "./createStack.ts"; import { platformFactory } from "./platform-node.ts"; +import { resolveConfig, resolveConfig as resolveConfigEffect } from "./StackConfigResolver.ts"; +import type { PortSet } from "./PortCatalog.ts"; +import { toStackHandle } from "./stackHandle.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"); +const handles: ForegroundStackHandle[] = []; +const testPorts: PortSet = { + apiPort: 40_000, + dbPort: 40_001, + authPort: 40_002, + postgrestPort: 40_003, + postgrestAdminPort: 40_004, + edgeRuntimePort: 40_005, + edgeRuntimeInspectorPort: 40_006, + realtimePort: 40_007, + storagePort: 40_008, + imgproxyPort: 40_009, + mailpitPort: 40_010, + mailpitSmtpPort: 40_011, + mailpitPop3Port: 40_012, + pgmetaPort: 40_013, + studioPort: 40_014, + analyticsPort: 40_015, + poolerPort: 40_016, + poolerApiPort: 40_017, }; -afterEach(async () => { - await Promise.all(handles.splice(0).map((handle) => handle.dispose())); +afterEach(() => { + const owned = handles.splice(0); + return Effect.runPromise(Effect.forEach(owned, (handle) => handle.dispose(), { discard: true })); }); describe("direct createStack port ownership", () => { it("allocates only active service fields without managed state", async () => { - const stack = await createStackWithFreshPorts( - { - mode: "native", - startupMode: "lazy", - postgrest: false, - auth: false, - edgeRuntime: false, - realtime: false, - storage: false, - imgproxy: false, - mailpit: false, - pgmeta: false, - studio: false, - analytics: false, - vector: false, - pooler: false, - }, - platformFactory, + const stack = await Effect.runPromise( + createStack( + { + mode: "native", + postgrest: false, + auth: false, + edgeRuntime: false, + realtime: false, + storage: false, + imgproxy: false, + mailpit: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, + }, + platformFactory, + { mode: "native", containerRuntime: null }, + resolveConfig, + ).pipe(Effect.provide(NodeServices.layer)), ); handles.push(stack); expect(stack.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); expect(stack.dbUrl).toMatch(/127\.0\.0\.1:\d+/); - const activeServices = new Set((await stack.getStatus()).map((state) => state.name)); + const activeServices = new Set( + (await Effect.runPromise(stack.getStatus())).map((state) => state.name), + ); expect(activeServices).not.toContain("studio"); expect(activeServices).not.toContain("analytics"); expect(activeServices).not.toContain("pooler"); }); + + it("isolates resolver-owned roots across repeated evaluations", async () => { + const createdPaths: string[] = []; + let failNextRoot = false; + const result = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const trackingFs = { + ...fs, + makeTempDirectory: (options: Parameters[0]) => + Effect.suspend(() => + failNextRoot + ? Effect.fail( + systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "makeTempDirectory", + description: "injected failure", + }), + ) + : fs + .makeTempDirectory(options) + .pipe(Effect.tap((path) => Effect.sync(() => createdPaths.push(path)))), + ), + }; + const resolver = resolveConfigEffect({ mode: "native" }, { ports: testPorts }); + const firstConfig = yield* resolver.pipe( + Effect.provideService(FileSystem.FileSystem, trackingFs), + ); + const firstPaths = [...firstConfig.autoManagedPaths]; + failNextRoot = true; + const secondExit = yield* resolver.pipe( + Effect.provideService(FileSystem.FileSystem, trackingFs), + Effect.exit, + ); + const firstPathsExist = firstPaths.map((path) => existsSync(path)); + yield* Effect.forEach( + firstPaths, + (path) => fs.remove(path, { recursive: true, force: true }), + { discard: true }, + ); + return { firstPaths, firstPathsExist, secondExit }; + }).pipe(Effect.provide(NodeFileSystem.layer)), + ); + + expect(result.firstPaths).toHaveLength(2); + expect(createdPaths).toEqual(result.firstPaths); + expect(result.firstPathsExist).toEqual([true, true]); + expect(Exit.isFailure(result.secondExit)).toBe(true); + if (Exit.isFailure(result.secondExit)) { + expect(Cause.findErrorOption(result.secondExit.cause)).toMatchObject({ + _tag: "Some", + value: { _tag: "StackBuildError" }, + }); + } + expect(result.firstPaths.every((path) => !existsSync(path))).toBe(true); + }); + + it("releases a resolver lease when creation is interrupted", async () => { + const leasedPort = Deferred.makeUnsafe(); + const resolveBlocked: ResolveConfigEffect = (_config, options) => + Effect.gen(function* () { + yield* Deferred.succeed(leasedPort, options?.ports.apiPort ?? 0); + return yield* Effect.never; + }); + + const fiber = Effect.runFork( + createStack( + { mode: "native" }, + platformFactory, + { mode: "native", containerRuntime: null }, + resolveBlocked, + ).pipe(Effect.provide(NodeServices.layer)), + ); + const port = await Effect.runPromise(Deferred.await(leasedPort)); + await Effect.runPromise(Fiber.interrupt(fiber)); + + const server = createServer(); + let bound = false; + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + bound = true; + resolve(); + }); + }); + } finally { + if (bound) { + await new Promise((resolve, reject) => { + server.close((error) => (error === undefined ? resolve() : reject(error))); + }); + } + } + expect(bound).toBe(true); + }); + + it("shares foreground disposal completion across concurrent callers", async () => { + const finalizerStarted = Deferred.makeUnsafe(); + const releaseFinalizer = Deferred.makeUnsafe(); + const gatedPlatformFactory: PlatformFactory = (options) => + Layer.mergeAll( + platformFactory(options), + Layer.effectDiscard( + Effect.addFinalizer(() => + Deferred.succeed(finalizerStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFinalizer)), + Effect.asVoid, + ), + ), + ), + ); + + const stack = await Effect.runPromise( + createStack( + { + mode: "native", + postgrest: false, + auth: false, + edgeRuntime: false, + realtime: false, + storage: false, + imgproxy: false, + mailpit: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, + }, + gatedPlatformFactory, + { mode: "native", containerRuntime: null }, + resolveConfig, + ).pipe(Effect.provide(NodeServices.layer)), + ); + handles.push(stack); + const publicStack = toStackHandle(stack); + + const secondInvoked = Deferred.makeUnsafe(); + const secondDone = Deferred.makeUnsafe(); + const firstDisposal = Effect.runFork(Effect.promise(() => publicStack.dispose())); + await Effect.runPromise(Deferred.await(finalizerStarted)); + const secondDisposal = Effect.runFork( + Effect.promise(() => { + Effect.runSync(Deferred.succeed(secondInvoked, undefined)); + return publicStack.dispose(); + }).pipe(Effect.andThen(Deferred.succeed(secondDone, undefined)), Effect.asVoid), + ); + + await Effect.runPromise(Deferred.await(secondInvoked)); + expect(Option.isNone(await Effect.runPromise(Deferred.poll(secondDone)))).toBe(true); + await Effect.runPromise(Deferred.succeed(releaseFinalizer, undefined)); + const [firstExit, secondExit] = await Effect.runPromise( + Effect.all([Fiber.await(firstDisposal), Fiber.await(secondDisposal)]), + ); + expect(firstExit._tag).toBe("Success"); + expect(secondExit._tag).toBe("Success"); + }); }); diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index a80dfc0b04..9e07ada577 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -1,10 +1,22 @@ import type { LogEntry } from "@supabase/process-compose"; -import { Context, Effect, FileSystem, type Layer, ManagedRuntime, Path, Stream } from "effect"; +import { + Cause, + Context, + Deferred, + Effect, + Exit, + FileSystem, + type Layer, + ManagedRuntime, + Path, + Stream, +} from "effect"; 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 { toStackError, type StackError } from "./errors.ts"; import type { FunctionsReloadConfig } from "./functions.ts"; import { foregroundLayer } from "./layers.ts"; import { LocalStackLifecycle } from "./LocalStack.ts"; @@ -12,7 +24,7 @@ import { reservePortSet, type PortLease } from "./PortAllocator.ts"; import { Stack } from "./Stack.ts"; import type { EdgeRuntimeReloadConfig } from "./Stack.ts"; import type { ReadyOptions, ResolvedStackConfig, StackConfig } from "./StackConfig.ts"; -import { resolveConfig } from "./StackConfigResolver.ts"; +import { portRequestsForConfig, type ResolveConfigOptions } from "./StackConfigResolver.ts"; import type { StackServiceState } from "./StackServiceState.ts"; type PlatformServices = @@ -22,6 +34,7 @@ type PlatformServices = | HttpServer.HttpServer; type PlatformLayer = Layer.Layer; + /** Supplies the platform HTTP server used by the stack and HTTP proxy. */ interface PlatformFactoryOptions { readonly apiPort: number; @@ -30,141 +43,226 @@ interface PlatformFactoryOptions { export type PlatformFactory = (options: PlatformFactoryOptions) => PlatformLayer; -/** @internal Converts operation failures and closes a terminal foreground runtime. */ -export async function runForegroundOperation( - operation: Promise, - isDisposed: () => Promise, - dispose: () => Promise, -): Promise { - try { - return await operation; - } catch (error: unknown) { - const stackError = toStackError(error); - if (await isDisposed()) { - await dispose(); - } - throw stackError; - } -} +export type ResolveConfigEffect = ( + input: StackConfig | undefined, + options: ResolveConfigOptions, +) => Effect.Effect; -export interface StackHandle extends AsyncDisposable { +/** The internal foreground handle; public adapters live at the package edge. */ +export interface ForegroundStackHandle { readonly url: string; readonly dbUrl: string; readonly publishableKey: string; readonly secretKey: string; - start(): Promise; - stop(): Promise; - dispose(): Promise; - startService(name: string): Promise; - stopService(name: string): Promise; - restartService(name: string): Promise; - reloadFunctions(opts?: FunctionsReloadConfig): Promise; - reloadEdgeRuntime(opts: EdgeRuntimeReloadConfig): Promise; - ready(opts?: ReadyOptions): Promise; - serviceReady(name: string, opts?: ReadyOptions): Promise; - getStatus(): Promise>; - getServiceStatus(name: string): Promise; - statusChanges(): AsyncIterable; - logs(): AsyncIterable; - serviceLogs(name: string): AsyncIterable; - logHistory(name: string, limit?: number): Promise>; + start(): Effect.Effect; + stop(): Effect.Effect; + dispose(): Effect.Effect; + startService(name: string): Effect.Effect; + stopService(name: string): Effect.Effect; + restartService(name: string): Effect.Effect; + reloadFunctions(opts?: FunctionsReloadConfig): Effect.Effect; + reloadEdgeRuntime(opts: EdgeRuntimeReloadConfig): Effect.Effect; + ready(opts?: ReadyOptions): Effect.Effect; + serviceReady(name: string, opts?: ReadyOptions): Effect.Effect; + getStatus(): Effect.Effect, StackError>; + getServiceStatus(name: string): Effect.Effect; + statusChanges(): Stream.Stream; + logs(): Stream.Stream; + serviceLogs(name: string): Stream.Stream; + logHistory(name: string, limit?: number): Effect.Effect, StackError>; } -export async function createStack( +/** @internal Converts operation failures and closes a terminal foreground runtime. */ +export function runForegroundOperation( + operation: Effect.Effect, + isDisposed: Effect.Effect, + dispose: Effect.Effect, +): Effect.Effect { + return operation.pipe( + Effect.catchCause((cause) => + Effect.uninterruptible( + Effect.gen(function* () { + if (yield* isDisposed) { + yield* dispose; + } + return yield* Effect.fail(toStackError(Cause.squash(cause))); + }), + ), + ), + ); +} + +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 = ( config: StackConfig | undefined, platformFactory: PlatformFactory, -): Promise { - let portLease: PortLease | undefined; - let resolved: ResolvedStackConfig; - try { - resolved = await resolveConfig(config, { - portAllocator: (requests, options) => - reservePortSet(requests, options).pipe( - Effect.tap((lease) => - Effect.sync(() => { - portLease = lease; - }), + runtimeSelection: StackRuntimeSelection, + resolveConfig: ResolveConfigEffect, + preferredApiPort?: number, +): Effect.Effect => + Effect.gen(function* () { + let portLease: PortLease | undefined; + let resolved: ResolvedStackConfig | undefined; + let disposeRuntime: Effect.Effect | undefined; + + const cleanup = Effect.uninterruptible( + Effect.gen(function* () { + if (disposeRuntime !== undefined) { + yield* disposeRuntime.pipe(Effect.ignore); + } + if (portLease !== undefined) { + yield* portLease.releaseAll.pipe(Effect.ignore); + } + if (resolved === undefined) { + return; + } + if (resolved.containerRuntime !== null) { + yield* dockerForceRemove( + resolved.containerRuntime, + candidateCleanupTargets(resolved).dockerContainerNames, + ).pipe(Effect.ignore); + } + yield* Effect.sync(() => cleanupAutoManagedPaths(resolved!)); + }), + ); + + const attempt = Effect.gen(function* () { + const requests = yield* portRequestsForConfig(config, { + runtime: runtimeSelection, + ...(preferredApiPort === undefined + ? {} + : { preferredPorts: { apiPort: preferredApiPort } }), + }); + const lease = yield* reservePortSet(requests); + portLease = lease; + resolved = yield* resolveConfig(config, { + runtime: runtimeSelection, + ports: lease.ports, + }); + + const fullLayer = foregroundLayer(resolved, platformFactory, lease); + const managedRuntime = ManagedRuntime.make(fullLayer); + disposeRuntime = managedRuntime.disposeEffect; + return yield* Effect.gen(function* () { + const services = yield* managedRuntime.contextEffect; + const localStack = Context.get(services, Stack); + const apiProxy = Context.get(services, ApiProxy); + const lifecycle = Context.get(services, LocalStackLifecycle); + const info = yield* Effect.provideContext(localStack.getInfo(), services); + + const disposalCompletion = Deferred.makeUnsafe>(); + let disposalStarted = false; + const awaitDisposal = Deferred.await(disposalCompletion).pipe( + Effect.flatMap((exit) => + Exit.isSuccess(exit) ? Effect.void : Effect.failCause(exit.cause), + ), + ); + const dispose = Effect.uninterruptibleMask((restore) => + Effect.suspend(() => { + if (disposalStarted) { + return restore(awaitDisposal); + } + disposalStarted = true; + return Effect.forkDetach( + managedRuntime.disposeEffect.pipe( + Effect.uninterruptible, + Effect.exit, + Effect.flatMap((exit) => Deferred.succeed(disposalCompletion, exit)), + Effect.asVoid, + ), + { startImmediately: true }, + ).pipe(Effect.asVoid, Effect.andThen(restore(awaitDisposal))); + }), + ); + const run = (effect: Effect.Effect) => + runForegroundOperation( + Effect.provideContext(effect, services), + Effect.provideContext(lifecycle.isDisposed, services), + dispose, + ); + + // The HTTP module has no response-flushed hook. Give the proxy's final + // 503 response a brief opportunity to leave the socket before closing + // the runtime after terminal lazy activation. + managedRuntime.runFork( + apiProxy.awaitTerminalFailure.pipe( + Effect.andThen(Effect.sleep("25 millis")), + Effect.andThen(dispose), + Effect.catchCause(() => Effect.void), ), - Effect.map((lease) => lease.ports), - ), - }); - } catch (error: unknown) { - if (portLease !== undefined) { - await Effect.runPromise(portLease.releaseAll); - } - throw error; - } - - if (portLease === undefined) { - throw new Error("Stack port allocation completed without a port lease"); - } - - try { - const fullLayer = foregroundLayer(resolved, platformFactory, portLease); - const runtime = ManagedRuntime.make(fullLayer); - - try { - const services = await runtime.context(); - const localStack = Context.get(services, Stack); - const apiProxy = Context.get(services, ApiProxy); - const lifecycle = Context.get(services, LocalStackLifecycle); - const info = await runtime.runPromise(localStack.getInfo()); - - let disposal: Promise | undefined; - const gracefulDispose = () => { - disposal ??= runtime.dispose().catch(() => {}); - return disposal; - }; - const run = (effect: Effect.Effect) => - runForegroundOperation( - runtime.runPromise(effect), - () => runtime.runPromise(lifecycle.isDisposed), - gracefulDispose, ); - // The HTTP module has no response-flushed hook. Give the proxy's final - // 503 response a brief opportunity to leave the socket before closing - // the runtime after terminal lazy activation. - void runtime - .runPromise(apiProxy.awaitTerminalFailure.pipe(Effect.andThen(Effect.sleep("25 millis")))) - .then(gracefulDispose) - .catch(() => {}); - - const stack: StackHandle = { - url: info.url, - dbUrl: info.dbUrl, - publishableKey: info.publishableKey, - secretKey: info.secretKey, - start: () => run(localStack.start()), - stop: () => run(localStack.stop()), - dispose: gracefulDispose, - startService: (name) => run(localStack.startService(name)), - stopService: (name) => run(localStack.stopService(name)), - restartService: (name) => run(localStack.restartService(name)), - reloadFunctions: (opts) => run(localStack.reloadFunctions(opts)), - reloadEdgeRuntime: (opts) => run(localStack.reloadEdgeRuntime(opts)), - ready: (opts) => run(localStack.waitAllReady(opts)), - serviceReady: (name, opts) => run(localStack.waitReady(name, opts)), - getStatus: () => run(localStack.getAllStates()), - getServiceStatus: (name) => run(localStack.getState(name)), - statusChanges: () => Stream.toAsyncIterableWith(localStack.allStateChanges(), services), - logs: () => Stream.toAsyncIterableWith(localStack.subscribeAllLogs(), services), - serviceLogs: (name) => Stream.toAsyncIterableWith(localStack.subscribeLogs(name), services), - logHistory: (name, limit) => run(localStack.logHistory(name, limit)), - [Symbol.asyncDispose]: gracefulDispose, - }; - - return stack; - } catch (error: unknown) { - await runtime.dispose().catch(() => {}); - throw error; - } - } catch (error: unknown) { - await Effect.runPromise(portLease.releaseAll); - await Effect.runPromise( - dockerForceRemove(candidateCleanupTargets(resolved).dockerContainerNames), + return { + url: info.url, + dbUrl: info.dbUrl, + publishableKey: info.publishableKey, + secretKey: info.secretKey, + start: () => run(localStack.start()), + stop: () => run(localStack.stop()), + dispose: () => dispose, + startService: (name: string) => run(localStack.startService(name)), + stopService: (name: string) => run(localStack.stopService(name)), + restartService: (name: string) => run(localStack.restartService(name)), + reloadFunctions: (opts?: FunctionsReloadConfig) => run(localStack.reloadFunctions(opts)), + reloadEdgeRuntime: (opts: EdgeRuntimeReloadConfig) => + run(localStack.reloadEdgeRuntime(opts)), + ready: (opts?: ReadyOptions) => run(localStack.waitAllReady(opts)), + serviceReady: (name: string, opts?: ReadyOptions) => + run(localStack.waitReady(name, opts)), + getStatus: () => run(localStack.getAllStates()), + getServiceStatus: (name: string) => run(localStack.getState(name)), + statusChanges: () => localStack.allStateChanges(), + logs: () => localStack.subscribeAllLogs(), + serviceLogs: (name: string) => localStack.subscribeLogs(name), + logHistory: (name: string, limit?: number) => run(localStack.logHistory(name, limit)), + } satisfies ForegroundStackHandle; + }); + }); + + return yield* attempt.pipe( + Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : cleanup)), + ); + }); + +export function createStack( + config: StackConfig | undefined, + platformFactory: PlatformFactory, + runtime: StackRuntimeSelection, + resolveConfig: ResolveConfigEffect, +): Effect.Effect { + const automaticApiPort = config?.port === undefined; + const loop = ( + attempt: number, + ): Effect.Effect => + createStackAttempt( + config, + platformFactory, + runtime, + resolveConfig, + attempt === 0 ? undefined : 0, + ).pipe( + Effect.catch((error) => + automaticApiPort && + isAddressInUse(error) && + attempt + 1 < MAX_AUTOMATIC_API_PORT_HANDOFF_ATTEMPTS + ? Effect.suspend(() => loop(attempt + 1)) + : Effect.fail(error), + ), ); - cleanupAutoManagedPaths(resolved); - throw toStackError(error); - } + + return loop(0).pipe(Effect.mapError(toStackError)); } diff --git a/packages/stack/src/createStack.unit.test.ts b/packages/stack/src/createStack.unit.test.ts index d0962a7191..b9367c2e8d 100644 --- a/packages/stack/src/createStack.unit.test.ts +++ b/packages/stack/src/createStack.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { NodeFileSystem } from "@effect/platform-node"; import { Effect } from "effect"; import { existsSync } from "node:fs"; import { basename, dirname } from "node:path"; @@ -7,13 +8,53 @@ import { dockerContainerName } from "./StackIdentity.ts"; import { runForegroundOperation } from "./createStack.ts"; import { StackReadinessError } from "./errors.ts"; import { shortTempPrefixRoot } from "./paths.ts"; -import { resolveConfig, sanitizeDaemonConfigInput } from "./StackConfigResolver.ts"; +import { + portRequestsForConfig, + resolveConfig as resolveConfigEffect, + type ResolveConfigOptions, + sanitizeDaemonConfigInput, +} from "./StackConfigResolver.ts"; import { DEFAULT_VERSIONS } from "./versions.ts"; +import type { PortField, PortSet } from "./PortCatalog.ts"; + +const testPorts = (config?: Parameters[0]): PortSet => { + const ports = { + apiPort: 40_000, + dbPort: 40_001, + authPort: 40_002, + postgrestPort: 40_003, + postgrestAdminPort: 40_004, + edgeRuntimePort: 40_005, + edgeRuntimeInspectorPort: 40_006, + realtimePort: 40_007, + storagePort: 40_008, + imgproxyPort: 40_009, + mailpitPort: 40_010, + mailpitSmtpPort: 40_011, + mailpitPop3Port: 40_012, + pgmetaPort: 40_013, + studioPort: 40_014, + analyticsPort: 40_015, + poolerPort: 40_016, + poolerApiPort: 40_017, + } satisfies Record; + return { ...ports, apiPort: config?.port ?? ports.apiPort }; +}; + +const resolveConfig = ( + config?: Parameters[0], + options?: Partial, +) => + Effect.runPromise( + resolveConfigEffect(config, { ...options, ports: options?.ports ?? testPorts(config) }).pipe( + Effect.provide(NodeFileSystem.layer), + ), + ); describe("foreground operation lifecycle", () => { it("disposes the foreground runtime after a direct readiness timeout", async () => { let disposeCount = 0; - const operation = Promise.reject( + const operation = Effect.fail( new StackReadinessError({ target: "stack", timeoutMs: 10, @@ -22,12 +63,14 @@ describe("foreground operation lifecycle", () => { ); await expect( - runForegroundOperation( - operation, - async () => true, - async () => { - disposeCount += 1; - }, + Effect.runPromise( + runForegroundOperation( + operation, + Effect.succeed(true), + Effect.sync(() => { + disposeCount += 1; + }), + ), ), ).rejects.toMatchObject({ code: "STACK_READINESS_TIMEOUT" }); expect(disposeCount).toBe(1); @@ -37,12 +80,14 @@ describe("foreground operation lifecycle", () => { let disposeCount = 0; await expect( - runForegroundOperation( - Promise.reject(new Error("service startup failed")), - async () => true, - async () => { - disposeCount += 1; - }, + Effect.runPromise( + runForegroundOperation( + Effect.fail(new Error("service startup failed")), + Effect.succeed(true), + Effect.sync(() => { + disposeCount += 1; + }), + ), ), ).rejects.toMatchObject({ code: "UNKNOWN" }); expect(disposeCount).toBe(1); @@ -52,12 +97,14 @@ describe("foreground operation lifecycle", () => { let disposeCount = 0; await expect( - runForegroundOperation( - Promise.reject(new Error("failed")), - async () => false, - async () => { - disposeCount += 1; - }, + Effect.runPromise( + runForegroundOperation( + Effect.fail(new Error("failed")), + Effect.succeed(false), + Effect.sync(() => { + disposeCount += 1; + }), + ), ), ).rejects.toMatchObject({ code: "UNKNOWN" }); expect(disposeCount).toBe(0); @@ -83,10 +130,37 @@ 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("auto"); + 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("docker"); + expect(config.containerRuntime).toBe("podman"); expect(config.edgeRuntime).toEqual( expect.objectContaining({ enabled: true, @@ -109,66 +183,80 @@ 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 }, + it("rejects an explicit zero port before invoking allocation", async () => { + await expect(resolveConfig({ mode: "native", port: 0 })).rejects.toMatchObject({ + _tag: "StackBuildError", + reason: "invalid_config", }); + }); - expect(config.ports.poolerPort).toBe(42423); - expect(config.ports.poolerApiPort).toBe(42424); + it.each([1.5, -1, 65_536])("rejects an explicit invalid port %s", async (port) => { + await expect(resolveConfig({ mode: "native", port })).rejects.toMatchObject({ + _tag: "StackBuildError", + reason: "invalid_config", + }); }); - it("orders explicit ports before omitted fields claim their preferred values", async () => { - const sharedCandidate = 61_234; + it("preserves an explicit pooler api port", async () => { const config = await resolveConfig( { - mode: "native", + mode: "docker", edgeRuntime: false, postgrest: false, auth: false, - analytics: { port: sharedCandidate }, + pooler: { port: 42423, apiPort: 42424 }, }, { - preferredPorts: { dbPort: sharedCandidate }, - portAllocator: (requests) => { - expect(requests[0]).toEqual({ - field: "analyticsPort", - selection: { kind: "exact", port: sharedCandidate }, - }); - return Effect.succeed({ - apiPort: 61_233, - dbPort: 61_235, - analyticsPort: sharedCandidate, - }); - }, + runtime: { mode: "docker", containerRuntime: "docker" }, + ports: { ...testPorts(), poolerPort: 42423, poolerApiPort: 42424 }, }, ); - expect(config.ports.analyticsPort).toBe(sharedCandidate); - expect(config.ports.dbPort).not.toBe(sharedCandidate); + expect(config.ports.poolerPort).toBe(42423); + expect(config.ports.poolerApiPort).toBe(42424); + }); + + it("orders explicit ports before automatic requests", async () => { + const sharedCandidate = 61_234; + const requests = await Effect.runPromise( + portRequestsForConfig( + { + mode: "native", + edgeRuntime: false, + postgrest: false, + auth: false, + analytics: { port: sharedCandidate }, + }, + { preferredPorts: { dbPort: sharedCandidate } }, + ), + ); + expect(requests[0]).toEqual({ + field: "analyticsPort", + selection: { kind: "exact", port: sharedCandidate }, + }); + expect(requests[1]?.field).toBe("apiPort"); }); }); 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 +268,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 +302,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 79f2e1542c..308340aa77 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 { @@ -53,7 +54,7 @@ export type { PortSelection, PortSelectionOptions, } from "./PortAllocator.ts"; -export { allocatePortSet, PortAllocationError, reservePortSet } from "./PortAllocator.ts"; +export { PortAllocationError, reservePortSet } from "./PortAllocator.ts"; export { AllocatedPortsSchema, DEFAULT_API_PORT, @@ -95,6 +96,9 @@ export type { ResolvedVectorConfig, ReadinessPolicy, ReadyOptions, + ServicePolicy, + ServicePolicyManifest, + StackMode, StackConfig, StorageConfig, StudioConfig, @@ -128,7 +132,6 @@ export { dockerImageForService, fillServiceVersionManifest, fullVersionManifest, - IMAGE_TAG_PREFIX, normalizeServiceVersion, normalizeServiceVersions, SERVICE_NAMES, @@ -146,7 +149,8 @@ export { NoRunningStackError } from "./managed/model.ts"; export type { PartialVersionManifest } from "./versions.ts"; export { PartialVersionManifestSchema } from "./versions.ts"; -export { resolveConfig } from "./StackConfigResolver.ts"; +export { portRequestsForConfig, resolveConfig } from "./StackConfigResolver.ts"; +export type { PortRequestOptions, ResolveConfigOptions } from "./StackConfigResolver.ts"; export { DaemonStartError } from "./layers.ts"; export type { ManagedDaemonConfigInput } from "./layers.ts"; diff --git a/packages/stack/src/errors.ts b/packages/stack/src/errors.ts index 4d30ce2c47..478ecaa045 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; @@ -43,6 +58,8 @@ export const isDockerDaemonDownMessage = (message: string): boolean => { normalized.includes("docker daemon is not running") || normalized.includes("docker desktop is not running") || normalized.includes("is the docker daemon running") || + normalized.includes("cannot connect to podman") || + normalized.includes("error during connect") || // Spawn succeeds but the socket is not accessible (e.g. a Linux user // missing docker group membership) — a local setup problem, not a // registry failure. @@ -126,6 +143,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..906923b15e 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; +import { NodeServices } from "@effect/platform-node"; import { mkdtempSync, symlinkSync } from "node:fs"; import { readFile, readdir, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Effect, Schema } from "effect"; -import { resolveConfig } from "./StackConfigResolver.ts"; +import { + resolveConfig as resolveConfigEffect, + type ResolveConfigOptions, +} from "./StackConfigResolver.ts"; +import type { PortSet } from "./PortCatalog.ts"; import { defaultJwtSecret, generateJwt } from "./JwtGenerator.ts"; import { clearFunctionsRuntimeConfig, @@ -17,6 +21,37 @@ import { } from "./functions.ts"; import { verifyRequest } from "./services/edge-runtime-main.ts"; +const testPorts: PortSet = { + apiPort: 40_000, + dbPort: 40_001, + authPort: 40_002, + postgrestPort: 40_003, + postgrestAdminPort: 40_004, + edgeRuntimePort: 40_005, + edgeRuntimeInspectorPort: 40_006, + realtimePort: 40_007, + storagePort: 40_008, + imgproxyPort: 40_009, + mailpitPort: 40_010, + mailpitSmtpPort: 40_011, + mailpitPop3Port: 40_012, + pgmetaPort: 40_013, + studioPort: 40_014, + analyticsPort: 40_015, + poolerPort: 40_016, + poolerApiPort: 40_017, +}; + +const resolveConfig = ( + config?: Parameters[0], + options?: Partial, +) => + Effect.runPromise( + resolveConfigEffect(config, { ...options, ports: options?.ports ?? testPorts }).pipe( + Effect.provide(NodeServices.layer), + ), + ); + function makeTempProject(): string { return mkdtempSync(join(tmpdir(), "supabase-stack-functions-")); } @@ -84,7 +119,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 +254,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); @@ -229,7 +274,7 @@ describe("stack Functions runtime config", () => { yield* clearFunctionsRuntimeConfig(stackConfig.runtimeRoot); expect(yield* Effect.promise(() => readdir(join(cwd, "edge-runtime")))).toEqual([]); }).pipe( - Effect.provide(BunServices.layer), + Effect.provide(NodeServices.layer), Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), ); }); diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 3969233f6f..a482ef0b0a 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,9 +26,9 @@ 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 { StackHandle } from "./stackHandle.ts"; export type { FunctionsReloadConfig, FunctionsRuntimeConfig, 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..3a09894979 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", @@ -154,6 +156,25 @@ describe("managed stack document store", () => { }).pipe(Effect.provide(filesystemLayer)), ); + it.live("rejects unknown launch modes as invalid managed documents", () => + Effect.gen(function* () { + const store = yield* makeTempStackStore(); + writeRawStackDocument( + store.stateRoot, + STACK_ID, + JSON.stringify({ ...document(), launch: { mode: "auto", versions: {} } }), + ); + + const exit = yield* store.read(STACK_ID).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toMatchObject({ + _tag: "InvalidManagedStackDocumentError", + }); + } + }).pipe(Effect.provide(filesystemLayer)), + ); + it.live("lists a corrupt stack beside healthy stacks", () => Effect.gen(function* () { const store = yield* makeTempStackStore(); 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/atomic-claim.ts b/packages/stack/src/managed/atomic-claim.ts index 4e7b8ead30..6c7ae96c00 100644 --- a/packages/stack/src/managed/atomic-claim.ts +++ b/packages/stack/src/managed/atomic-claim.ts @@ -1,73 +1,100 @@ import { randomUUID } from "node:crypto"; -import { link, unlink, writeFile } from "node:fs/promises"; -import { errorCode } from "./error-code.ts"; +import { Cause, Effect, FileSystem, Option, PlatformError } from "effect"; export type FileClaimOutcome = "claimed" | "already-exists"; export interface FileClaimOptions { /** Mode for the published file; defaults to the process umask. */ readonly mode?: number; - /** - * The hardlink step, overridable so a test can drive the hardlink-less - * fallback on a filesystem that does support hardlinks. - */ - readonly linkFile?: (existingPath: string, newPath: string) => Promise; } -const createExclusively = async ( - targetPath: string, +const isAlreadyExists = (error: PlatformError.PlatformError): boolean => + error.reason._tag === "AlreadyExists"; + +const removeOwnedFile = (fs: FileSystem.FileSystem, path: string): Effect.Effect => + fs.remove(path, { force: true }).pipe(Effect.ignore); + +/** + * Writes an owned file with an interruption-safe open handoff. Exclusive open + * and ownership recording stay masked as one region; only the subsequent write + * is restored, so a created pathname can never escape without cleanup ownership. + */ +const writeOwnedFile = ( + fs: FileSystem.FileSystem, + path: string, content: string, mode: number | undefined, -): Promise => { - try { - await writeFile(targetPath, content, { flag: "wx", mode }); - return "claimed"; - } catch (error: unknown) { - if (errorCode(error) === "EEXIST") { - return "already-exists"; +): Effect.Effect => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + let attempted = false; + let owned = false; + const result = yield* Effect.exit( + Effect.scoped( + Effect.gen(function* () { + // The surrounding mask covers open and this handoff assignment. + attempted = true; + const file = yield* fs.open(path, { flag: "wx", mode }); + owned = true; + yield* restore(file.writeAll(new TextEncoder().encode(content))); + }), + ), + ); + if (result._tag === "Success") return; + const error = Cause.findErrorOption(result.cause); + const interruptedBeforeOwnership = + attempted && + Cause.hasInterrupts(result.cause) && + (Option.isNone(error) || !isAlreadyExists(error.value)); + if (owned || interruptedBeforeOwnership) { + yield* removeOwnedFile(fs, path); + } + return yield* Effect.failCause(result.cause); + }), + ); + +const publish = ( + fs: FileSystem.FileSystem, + temporaryPath: string, + targetPath: string, +): Effect.Effect => + Effect.gen(function* () { + // A hard link publishes the complete, closed temp atomically and refuses an + // existing target. Unsupported links fail as typed PlatformError rather than + // falling back to a direct target write or an unprovable sidecar protocol. + const linked = yield* Effect.exit(fs.link(temporaryPath, targetPath)); + if (linked._tag === "Success") return "claimed" as const; + const linkError = Cause.findErrorOption(linked.cause); + if (Option.isNone(linkError)) return yield* Effect.failCause(linked.cause); + if (isAlreadyExists(linkError.value)) { + const readable = yield* Effect.exit(fs.readFile(targetPath)); + if (readable._tag === "Success") return "already-exists" as const; + return yield* Effect.failCause(readable.cause); } - throw error; - } -}; + return yield* Effect.fail(linkError.value); + }); /** * Publishes `content` at `targetPath` unless a claimant got there first. * - * The content is written to a sibling temporary file and hardlinked into place, - * because `link` publishes the whole file in one step and refuses an existing - * target: writing `targetPath` directly could crash halfway and publish a - * partial claim, and testing for the file before writing it would lose the very - * race the claim exists to settle. Filesystems without hardlinks — exFAT, - * FAT32, some network mounts — refuse `link` with `EPERM` or `ENOTSUP`; those - * fall back to an exclusive create, which still settles the race but gives up - * the all-or-nothing publish. Any other failure is a real one and propagates. - * - * A `SIGKILL` between the temporary write and its removal strands a - * `.tmp.` sibling. Nothing ever reads those, so a stranded one is junk - * rather than a claim anybody can observe, and every attempt gets a fresh - * temporary path so a concurrent claimant cannot overwrite its source. + * Every claimant writes a unique sibling completely before publication. The + * hard-link publication is the sole publication primitive: it exposes only a + * complete temp and never overwrites a winner. Filesystems that reject hard + * links fail as a typed PlatformError after exact temporary cleanup. */ -export const claimFileAtomically = async ( +export const claimFileAtomically = ( targetPath: string, content: string, options: FileClaimOptions = {}, -): Promise => { - const linkFile = options.linkFile ?? link; - const temporaryPath = `${targetPath}.tmp.${randomUUID()}`; - await writeFile(temporaryPath, content, { mode: options.mode }); - try { - await linkFile(temporaryPath, targetPath); - return "claimed"; - } catch (error: unknown) { - const code = errorCode(error); - if (code === "EEXIST") { - return "already-exists"; - } - if (code !== "EPERM" && code !== "ENOTSUP") { - throw error; - } - return await createExclusively(targetPath, content, options.mode); - } finally { - await unlink(temporaryPath).catch(() => undefined); - } -}; +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const temporaryPath = `${targetPath}.tmp.${randomUUID()}`; + + // Register exact temp cleanup before entering interruptible publication. + return yield* Effect.acquireUseRelease( + writeOwnedFile(fs, temporaryPath, content, options.mode).pipe(Effect.as(temporaryPath)), + () => publish(fs, temporaryPath, targetPath), + (ownedPath) => removeOwnedFile(fs, ownedPath), + ); + }); diff --git a/packages/stack/src/managed/document.ts b/packages/stack/src/managed/document.ts index 843d967e16..160e1b39aa 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,32 @@ 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 +59,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([ @@ -118,12 +138,29 @@ export class InvalidManagedStackDocumentError extends Data.TaggedError( const decodeDocument = Schema.decodeUnknownSync(ManagedStackDocumentSchema); const encodeDocument = Schema.encodeUnknownSync(managedStackDocumentSchema); +const hasUnknownLaunchMode = (content: string): boolean => { + let value: unknown; + try { + value = JSON.parse(content); + } catch { + return false; + } + if (typeof value !== "object" || value === null) return false; + const launch = Reflect.get(value, "launch"); + if (typeof launch !== "object" || launch === null) return false; + const mode = Reflect.get(launch, "mode"); + return mode !== undefined && mode !== "native" && mode !== "docker"; +}; + export const decodeManagedStackDocument = ( path: string, content: string, ): Effect.Effect => Effect.try({ try: () => { + if (hasUnknownLaunchMode(content)) { + throw new Error("Managed document has an unknown launch mode"); + } const document = decodeDocument(content); if (!hasCorePortAssignments(document)) { throw new Error("Managed document is missing core port assignments"); diff --git a/packages/stack/src/managed/error-code.ts b/packages/stack/src/managed/error-code.ts deleted file mode 100644 index a77ceb2b16..0000000000 --- a/packages/stack/src/managed/error-code.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * The `code` carried by Node's filesystem/process errors and by the SQLite - * drivers. Reading it structurally keeps the managed layer free of driver - * imports and of message-text matching. - */ -export const errorCode = (error: unknown): string | undefined => { - if (typeof error !== "object" || error === null) { - return undefined; - } - const code = Reflect.get(error, "code"); - return typeof code === "string" ? code : undefined; -}; diff --git a/packages/stack/src/managed/failure.ts b/packages/stack/src/managed/failure.ts index f3f6c79e90..3415f9f1f3 100644 --- a/packages/stack/src/managed/failure.ts +++ b/packages/stack/src/managed/failure.ts @@ -23,10 +23,6 @@ export const causeMessage = (cause: unknown): string => { * defect instead of widening a method's error channel to `unknown`. * * Both rethrowing handlers here are therefore for `Effect.try` only. - * `Effect.tryPromise` calls its `catch` handler from inside the promise chain - * the runtime is awaiting, so a handler that rethrows there escapes into that - * chain instead of becoming a defect. An asynchronous call sorts its failures - * after the fact instead, with {@link asRaised} and {@link failsOnlyWith}. * * The expected union must be named explicitly, because TypeScript infers a * single class from a variadic list of unrelated constructors instead of @@ -63,10 +59,8 @@ export const failsWith = * inventing a protocol failure for them would hide what actually went wrong. * * The sorting happens after the effect fails rather than inside a `tryPromise` - * `catch` handler: `Effect.try` turns a throwing handler into a defect, but a - * `tryPromise` handler that throws does so inside the promise chain the runtime - * is awaiting, where nothing is watching for it. Such a call therefore pairs a - * handler that classifies nothing — see {@link asRaised} — with this recovery. + * `catch` handler: `Effect.try` turns a throwing handler into a defect. Effects + * that need this recovery classify their own failures before reaching it. */ export const failsOnlyWith = (failure: abstract new (...args: never[]) => E) => @@ -74,6 +68,3 @@ export const failsOnlyWith = Effect.catch(effect, (error) => error instanceof failure ? Effect.fail(error) : Effect.die(error), ); - -/** A `catch` handler that classifies nothing, so it can never throw. */ -export const asRaised = (error: unknown): unknown => error; diff --git a/packages/stack/src/managed/git.integration.test.ts b/packages/stack/src/managed/git.integration.test.ts index f22cea0a28..0fbd6728e4 100644 --- a/packages/stack/src/managed/git.integration.test.ts +++ b/packages/stack/src/managed/git.integration.test.ts @@ -1,6 +1,6 @@ import { BunFileSystem } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer } from "effect"; +import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect"; import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach } from "vitest"; @@ -17,7 +17,8 @@ import { inspectWorkspace, type GitCheckoutInspection, } from "./git.ts"; -import { UnsupportedGitWorkspaceError } from "./model.ts"; +import { ensureOrdinaryWorkspaceIdentity } from "./identity.ts"; +import { InvalidManagedIdentityError, UnsupportedGitWorkspaceError } from "./model.ts"; const { makeRoot, removeAll } = temporaryRoots("managed-git-test-"); const gitLayer = Layer.mergeAll(BunFileSystem.layer, gitConfigStoreLayer); @@ -132,6 +133,110 @@ describe("managed Git workspace identity", () => { expect(new Set(identities.map((identity) => identity.checkoutId)).size).toBe(3); expect(first.checkoutKind).toBe("linked-worktree"); expect(second.checkoutKind).toBe("linked-worktree"); + const thirdPath = join(root, "third"); + git(repository, "worktree", "add", "-q", thirdPath, "-b", "feature/third"); + const third = yield* inspectCheckout(thirdPath); + const raced = yield* Effect.all( + [ensureGitCheckoutIdentity(third), ensureGitCheckoutIdentity(third)], + { concurrency: "unbounded" }, + ); + expect(new Set(raced.map((identity) => identity.checkoutId)).size).toBe(1); + expect(raced.filter((identity) => identity.checkoutIdentityCreated)).toHaveLength(1); }).pipe(Effect.provide(gitLayer)), ); + + it.live("cleans an ordinary identity temp after interruption races exclusive open", () => + Effect.gen(function* () { + const root = makeRoot(); + const workspace = makeDirectory(root, "workspace"); + const markerPath = join(workspace, ".supabase", "identity.json"); + let tempOpenBlocked = false; + const layer = Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (fs) => ({ + ...fs, + open: (path, options) => { + if ( + !tempOpenBlocked && + path.startsWith(`${markerPath}.tmp.`) && + options?.flag === "wx" + ) { + tempOpenBlocked = true; + return fs + .open(path, options) + .pipe(Effect.flatMap((file) => Effect.interrupt.pipe(Effect.as(file)))); + } + return fs.open(path, options); + }, + })), + ).pipe(Layer.provide(BunFileSystem.layer)); + + const interrupted = yield* ensureOrdinaryWorkspaceIdentity(workspace).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(interrupted)).toBe(true); + + const fs = yield* Effect.gen(function* () { + return yield* FileSystem.FileSystem; + }).pipe(Effect.provide(layer)); + expect(yield* fs.exists(markerPath)).toBe(false); + expect(yield* fs.readDirectory(join(workspace, ".supabase"))).toEqual([]); + + const retry = yield* ensureOrdinaryWorkspaceIdentity(workspace).pipe(Effect.provide(layer)); + expect(retry.created).toBe(true); + }), + ); + + it.live("fails with a typed error when hard-link publication is unsupported", () => + Effect.gen(function* () { + const root = makeRoot(); + const repository = makeRepository(root); + const checkout = yield* inspectCheckout(repository).pipe(Effect.provide(gitLayer)); + const markerPath = join(checkout.gitDirectory, "supabase-checkout.json"); + const layer = Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (fs) => ({ + ...fs, + link: () => + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "link", + cause: Object.assign(new Error("hard links unavailable"), { code: "EPERM" }), + }), + ), + })), + ); + const providedLayer = Layer.mergeAll( + layer.pipe(Layer.provide(BunFileSystem.layer)), + gitConfigStoreLayer, + ); + + const failure = yield* Effect.flip( + ensureGitCheckoutIdentity(checkout).pipe(Effect.provide(providedLayer)), + ); + expect(failure).toBeInstanceOf(UnsupportedGitWorkspaceError); + const fs = yield* Effect.gen(function* () { + return yield* FileSystem.FileSystem; + }).pipe(Effect.provide(providedLayer)); + expect(yield* fs.exists(markerPath)).toBe(false); + expect( + (yield* fs.readDirectory(checkout.gitDirectory)).filter((entry) => + entry.startsWith("supabase-checkout.json.tmp."), + ), + ).toEqual([]); + + const workspace = makeDirectory(root, "ordinary"); + const ordinaryFailure = yield* Effect.flip( + ensureOrdinaryWorkspaceIdentity(workspace).pipe(Effect.provide(providedLayer)), + ); + expect(ordinaryFailure).toBeInstanceOf(InvalidManagedIdentityError); + expect(ordinaryFailure.message).toContain("hard links"); + const ordinaryMetadata = join(workspace, ".supabase"); + expect(yield* fs.exists(join(ordinaryMetadata, "identity.json"))).toBe(false); + expect(yield* fs.readDirectory(ordinaryMetadata)).toEqual([]); + }), + ); }); diff --git a/packages/stack/src/managed/git.ts b/packages/stack/src/managed/git.ts index 5f08c93547..a19fcb2f4a 100644 --- a/packages/stack/src/managed/git.ts +++ b/packages/stack/src/managed/git.ts @@ -1,12 +1,10 @@ import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { Context, Duration, Effect, FileSystem, Layer, Schedule, type PlatformError } from "effect"; import { claimFileAtomically } from "./atomic-claim.ts"; -import { errorCode } from "./error-code.ts"; -import { asRaised, failsOnlyWith, failsWith } from "./failure.ts"; +import { failsOnlyWith, failsWith } from "./failure.ts"; import { assertManagedUuid, createManagedUuid } from "./ids.ts"; import { ensureGitCheckoutLocation, readGitCheckoutLocation } from "./identity.ts"; import { decodeGitCheckoutIdentity } from "./git-identity.ts"; @@ -558,9 +556,20 @@ const runGitConfig = ( args: ReadonlyArray, tolerateUnset: boolean, file: string, -): Promise => - new Promise((settle) => { - execFile("git", ["config", ...args], { encoding: "utf8" }, (error, stdout, stderr) => { +): Effect.Effect => + Effect.callback((resume) => { + let settled = false; + let child: ReturnType | undefined; + const settle = (result: GitConfigResult) => { + if (settled) return; + settled = true; + resume(Effect.succeed(result)); + }; + const onComplete = ( + error: (Error & { readonly code?: number | string }) | null, + stdout: string, + stderr: string, + ) => { if (error === null) { settle({ kind: "answered", stdout }); return; @@ -595,6 +604,17 @@ const runGitConfig = ( status: typeof exitCode === "number" ? exitCode : undefined, }); } + }; + try { + child = execFile("git", ["config", ...args], { encoding: "utf8" }, onComplete); + } catch (cause) { + settle({ kind: "failed", detail: `git config could not be spawned (${String(cause)})` }); + } + return Effect.sync(() => { + settled = true; + try { + child?.kill(); + } catch {} }); }); @@ -616,9 +636,8 @@ const gitConfig = ( Effect.flatMap( Effect.catch( Effect.retry( - Effect.flatMap( - Effect.promise(() => runGitConfig(args, tolerateUnset, file)), - (result) => (result.kind === "retryable" ? Effect.fail(result) : Effect.succeed(result)), + Effect.flatMap(runGitConfig(args, tolerateUnset, file), (result) => + result.kind === "retryable" ? Effect.fail(result) : Effect.succeed(result), ), { while: (error) => error.kind === "retryable", @@ -774,92 +793,87 @@ const ensureConfigId = ( return id; }); -const readCheckoutIdentity = async ( - gitDirectory: string, -): Promise => { - try { - return decodeGitCheckoutIdentity(await readFile(gitCheckoutIdentityPath(gitDirectory), "utf8")); - } catch (error: unknown) { - if (errorCode(error) === "ENOENT") { - return undefined; - } - if ( - error instanceof InvalidManagedIdentityError || - error instanceof UnsupportedGitWorkspaceError - ) { - throw error; - } - throw new UnsupportedGitWorkspaceError({ - path: gitCheckoutIdentityPath(gitDirectory), - reason: `Git checkout identity is inaccessible (${errorCode(error) ?? String(error)})`, - workspaceCause: "metadata-inaccessible", - }); - } -}; - -/** - * Claiming a checkout stays one `await` chain, for the reason - * `ensureOrdinaryWorkspaceIdentity` does: reading the marker, publishing the - * claim, and re-reading the marker a losing claimant must adopt are a single - * indivisible protocol, and an interruption between those steps would leave the - * caller with a checkout identity no git directory agreed to. - * - * The git directory always exists by the time this runs — inspection found it — - * so the marker needs no directory created for it. - */ interface CheckoutIdentityClaim { readonly checkoutId: string; /** Whether this call published the marker, rather than adopting a winner's. */ readonly created: boolean; } -const ensureCheckoutIdentity = async ( +const readCheckoutIdentity = ( + gitDirectory: string, +): Effect.Effect< + GitCheckoutIdentity | undefined, + InvalidManagedIdentityError | UnsupportedGitWorkspaceError, + FileSystem.FileSystem +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const content = yield* fs.readFileString(gitCheckoutIdentityPath(gitDirectory)).pipe( + Effect.catchTag("PlatformError", (error) => + error.reason._tag === "NotFound" + ? Effect.succeed(undefined) + : Effect.fail( + new UnsupportedGitWorkspaceError({ + path: gitCheckoutIdentityPath(gitDirectory), + reason: `Git checkout identity is inaccessible (${error.message})`, + workspaceCause: "metadata-inaccessible", + }), + ), + ), + ); + return content === undefined + ? undefined + : yield* Effect.try({ + try: () => decodeGitCheckoutIdentity(content), + catch: failsWith(InvalidManagedIdentityError), + }); + }); + +const ensureCheckoutIdentity = ( gitDirectory: string, idFactory: () => string, -): Promise => { - const existing = await readCheckoutIdentity(gitDirectory); - if (existing !== undefined) { - return { checkoutId: existing.checkoutId, created: false }; - } +): Effect.Effect< + CheckoutIdentityClaim, + InvalidManagedIdentityError | UnsupportedGitWorkspaceError, + FileSystem.FileSystem +> => + Effect.gen(function* () { + const existing = yield* readCheckoutIdentity(gitDirectory); + if (existing !== undefined) return { checkoutId: existing.checkoutId, created: false }; - const identity: GitCheckoutIdentity = { - version: GIT_CHECKOUT_IDENTITY_VERSION, - checkoutId: createManagedUuid(idFactory, "checkoutId"), - }; - let outcome: Awaited>; - try { - outcome = await claimFileAtomically( + const identity: GitCheckoutIdentity = { + version: GIT_CHECKOUT_IDENTITY_VERSION, + checkoutId: createManagedUuid(idFactory, "checkoutId"), + }; + const outcome = yield* claimFileAtomically( gitCheckoutIdentityPath(gitDirectory), `${JSON.stringify(identity, null, 2)}\n`, - { - mode: 0o600, - }, + { mode: 0o600 }, + ).pipe( + Effect.catchTag("PlatformError", (error) => + Effect.fail( + new UnsupportedGitWorkspaceError({ + path: gitCheckoutIdentityPath(gitDirectory), + reason: `Git checkout identity is inaccessible (${error.message})`, + workspaceCause: "metadata-inaccessible", + }), + ), + ), ); - } catch (error: unknown) { - if ( - error instanceof InvalidManagedIdentityError || - error instanceof UnsupportedGitWorkspaceError - ) { - throw error; + if (outcome === "claimed") { + return { checkoutId: identity.checkoutId, created: true }; } - throw new UnsupportedGitWorkspaceError({ - path: gitCheckoutIdentityPath(gitDirectory), - reason: `Git checkout identity is inaccessible (${errorCode(error) ?? String(error)})`, - workspaceCause: "metadata-inaccessible", - }); - } - if (outcome === "claimed") { - return { checkoutId: identity.checkoutId, created: true }; - } - const winner = await readCheckoutIdentity(gitDirectory); - if (winner === undefined) { - throw new InvalidManagedIdentityError({ - message: "Checkout identity publication raced without a winning marker", - }); - } - return { checkoutId: winner.checkoutId, created: false }; -}; + const winner = yield* readCheckoutIdentity(gitDirectory); + if (winner === undefined) { + return yield* Effect.fail( + new InvalidManagedIdentityError({ + message: "Checkout identity publication raced without a winning marker", + }), + ); + } + return { checkoutId: winner.checkoutId, created: false }; + }); export interface EnsureGitCheckoutIdentityResult { readonly workspaceId: string; @@ -902,7 +916,7 @@ export const ensureGitCheckoutIdentity = ( ): Effect.Effect< EnsureGitCheckoutIdentityResult, InvalidManagedIdentityError | UnsupportedGitWorkspaceError, - GitConfigStore + GitConfigStore | FileSystem.FileSystem > => failsWithIdentity( Effect.gen(function* () { @@ -912,10 +926,7 @@ export const ensureGitCheckoutIdentity = ( "workspaceId", idFactory, ); - const checkoutClaim = yield* Effect.tryPromise({ - try: () => ensureCheckoutIdentity(inspection.gitDirectory, idFactory), - catch: asRaised, - }); + const checkoutClaim = yield* ensureCheckoutIdentity(inspection.gitDirectory, idFactory); yield* ensureGitCheckoutLocation(inspection.gitDirectory, inspection.workspaceRoot); return { workspaceId, diff --git a/packages/stack/src/managed/identity.ts b/packages/stack/src/managed/identity.ts index e69a923e3a..fc6814e051 100644 --- a/packages/stack/src/managed/identity.ts +++ b/packages/stack/src/managed/identity.ts @@ -1,16 +1,14 @@ import { randomUUID } from "node:crypto"; -import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; -import { Effect, FileSystem, type PlatformError } from "effect"; -import { claimFileAtomically } from "./atomic-claim.ts"; +import { Effect, FileSystem, PlatformError } from "effect"; +import { claimFileAtomically, type FileClaimOutcome } from "./atomic-claim.ts"; import { InvalidManagedIdentityError, ORDINARY_WORKSPACE_IDENTITY_VERSION, type OrdinaryWorkspaceIdentity, } from "./model.ts"; import { assertManagedUuid, createManagedUuid } from "./ids.ts"; -import { asRaised, failsOnlyWith, failsWith } from "./failure.ts"; -import { errorCode } from "./error-code.ts"; +import { failsOnlyWith, failsWith } from "./failure.ts"; import { gitCheckoutLocationPath, gitDetachedContextIdentityPath, @@ -18,11 +16,6 @@ import { } from "./paths.ts"; import type { ControlOwnership } from "./control.ts"; -/** - * The marker's own failures are the only ones this module reports. Every - * protocol step here is a promise, so each one pairs a `catch` handler that - * classifies nothing with a recovery that sorts the failure afterwards. - */ const failsWithIdentity = failsOnlyWith(InvalidManagedIdentityError); const identityField = (value: unknown, field: string): string => { @@ -66,6 +59,28 @@ const decodeIdentity = (content: string): OrdinaryWorkspaceIdentity => { }; }; +const inaccessibleIdentity = ( + label: string, + error: PlatformError.PlatformError, +): InvalidManagedIdentityError => + new InvalidManagedIdentityError({ message: `${label} is inaccessible (${error.message})` }); + +const claimIdentityFile = ( + path: string, + content: string, + label: string, + mode?: number, +): Effect.Effect => + claimFileAtomically(path, content, { mode }).pipe( + Effect.catchTag("PlatformError", (error) => + Effect.fail( + new InvalidManagedIdentityError({ + message: `${label} could not be published at ${path}: ${error.message}. The filesystem must support hard links for managed identity publication.`, + }), + ), + ), + ); + /** Effect FileSystem variant used by managed discovery. */ export const canonicalizeManagedWorkspacePathWithFileSystem = ( workspacePath: string, @@ -91,22 +106,7 @@ export const canonicalizeManagedWorkspacePathWithFileSystem = ( ), ); -const readIdentity = async ( - workspacePath: string, -): Promise => { - const markerPath = ordinaryWorkspaceIdentityPath(workspacePath); - try { - return decodeIdentity(await readFile(markerPath, "utf8")); - } catch (error: unknown) { - if (errorCode(error) === "ENOENT") { - return undefined; - } - throw error; - } -}; - -/** Read-only marker probe through Effect FileSystem; absence remains undefined. */ -export const readOrdinaryWorkspaceIdentityWithFileSystem = ( +const readIdentity = ( workspacePath: string, ): Effect.Effect< OrdinaryWorkspaceIdentity | undefined, @@ -116,82 +116,70 @@ export const readOrdinaryWorkspaceIdentityWithFileSystem = ( failsWithIdentity( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - try { - return decodeIdentity( - yield* fs.readFileString(ordinaryWorkspaceIdentityPath(workspacePath)), - ); - } catch (error) { - if (error instanceof InvalidManagedIdentityError) return yield* Effect.fail(error); - throw error; - } - }).pipe( - Effect.catchTag("PlatformError", (error: PlatformError.PlatformError) => - error.reason._tag === "NotFound" - ? Effect.succeed(undefined) - : Effect.fail( - new InvalidManagedIdentityError({ - message: `Ordinary workspace identity is inaccessible (${error.message})`, - }), - ), - ), - ), + return yield* fs.readFileString(ordinaryWorkspaceIdentityPath(workspacePath)).pipe( + Effect.flatMap((content) => + Effect.try({ + try: () => decodeIdentity(content), + catch: failsWith(InvalidManagedIdentityError), + }), + ), + Effect.catchTag("PlatformError", (error) => + error.reason._tag === "NotFound" + ? Effect.succeed(undefined) + : Effect.fail(inaccessibleIdentity("Ordinary workspace identity", error)), + ), + ); + }), ); +/** Read-only marker probe through Effect FileSystem; absence remains undefined. */ +export const readOrdinaryWorkspaceIdentityWithFileSystem = readIdentity; + export interface EnsureOrdinaryWorkspaceIdentityResult { readonly identity: OrdinaryWorkspaceIdentity; readonly created: boolean; readonly markerPath: string; } -/** - * Claiming a workspace stays one `await` chain rather than an `Effect.gen` - * pipeline: reading the marker, publishing the claim, and re-reading the marker - * a losing claimant must adopt are a single indivisible protocol, and an - * interruption between those steps would leave the caller with an identity no - * workspace agreed to. - */ -const ensureIdentity = async ( - workspacePath: string, - idFactory: () => string, -): Promise => { - const existing = await readIdentity(workspacePath); - const markerPath = ordinaryWorkspaceIdentityPath(workspacePath); - if (existing !== undefined) { - return { identity: existing, created: false, markerPath }; - } - - const identity: OrdinaryWorkspaceIdentity = { - version: ORDINARY_WORKSPACE_IDENTITY_VERSION, - workspaceId: createManagedUuid(idFactory, "workspaceId"), - checkoutId: createManagedUuid(idFactory, "checkoutId"), - contextId: createManagedUuid(idFactory, "contextId"), - }; - - await mkdir(dirname(markerPath), { recursive: true }); - const outcome = await claimFileAtomically(markerPath, `${JSON.stringify(identity, null, 2)}\n`, { - mode: 0o600, - }); - if (outcome === "claimed") { - return { identity, created: true, markerPath }; - } - - const winner = await readIdentity(workspacePath); - if (winner === undefined) { - throw new InvalidManagedIdentityError({ - message: "Identity publication raced without a winning marker", - }); - } - return { identity: winner, created: false, markerPath }; -}; - export const ensureOrdinaryWorkspaceIdentity = ( workspacePath: string, idFactory: () => string = randomUUID, -): Effect.Effect => +): Effect.Effect< + EnsureOrdinaryWorkspaceIdentityResult, + InvalidManagedIdentityError, + FileSystem.FileSystem +> => failsWithIdentity( - Effect.tryPromise({ - try: () => ensureIdentity(workspacePath, idFactory), - catch: asRaised, + Effect.gen(function* () { + const markerPath = ordinaryWorkspaceIdentityPath(workspacePath); + const existing = yield* readIdentity(workspacePath); + if (existing !== undefined) return { identity: existing, created: false, markerPath }; + + const identity: OrdinaryWorkspaceIdentity = { + version: ORDINARY_WORKSPACE_IDENTITY_VERSION, + workspaceId: createManagedUuid(idFactory, "workspaceId"), + checkoutId: createManagedUuid(idFactory, "checkoutId"), + contextId: createManagedUuid(idFactory, "contextId"), + }; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(dirname(markerPath), { recursive: true }); + const outcome = yield* claimIdentityFile( + markerPath, + `${JSON.stringify(identity, null, 2)}\n`, + "Ordinary workspace identity", + 0o600, + ); + if (outcome === "claimed") return { identity, created: true, markerPath }; + + const winner = yield* readIdentity(workspacePath); + if (winner === undefined) { + return yield* Effect.fail( + new InvalidManagedIdentityError({ + message: "Identity publication raced without a winning marker", + }), + ); + } + return { identity: winner, created: false, markerPath }; }), ); @@ -223,82 +211,63 @@ const decodeDetachedContextId = (content: string): string => { return assertManagedUuid(contextId, "contextId"); }; -const readDetachedContextId = async (gitDirectory: string): Promise => { - try { - return decodeDetachedContextId( - await readFile(gitDetachedContextIdentityPath(gitDirectory), "utf8"), - ); - } catch (error: unknown) { - if (errorCode(error) === "ENOENT") return undefined; - throw error; - } -}; - -export const readDetachedContextIdentity = ( - gitDirectory: string, -): Effect.Effect => - failsWithIdentity( - Effect.tryPromise({ try: () => readDetachedContextId(gitDirectory), catch: asRaised }), - ); - -export const ensureDetachedContextIdentity = ( - gitDirectory: string, - idFactory: () => string = randomUUID, -): Effect.Effect< - { readonly contextId: string; readonly created: boolean }, - InvalidManagedIdentityError -> => - failsWithIdentity( - Effect.tryPromise({ - try: async () => { - const existing = await readDetachedContextId(gitDirectory); - if (existing !== undefined) return { contextId: existing, created: false }; - const contextId = createManagedUuid(idFactory, "contextId"); - const markerPath = gitDetachedContextIdentityPath(gitDirectory); - const outcome = await claimFileAtomically( - markerPath, - `${JSON.stringify({ version: DETACHED_CONTEXT_VERSION, contextId }, null, 2)}\n`, - { mode: 0o600 }, - ); - if (outcome === "claimed") return { contextId, created: true }; - const winner = await readDetachedContextId(gitDirectory); - if (winner === undefined) { - throw new InvalidManagedIdentityError({ - message: "Detached context publication raced without a winning marker", - }); - } - return { contextId: winner, created: false }; - }, - catch: asRaised, - }), - ); - -export const readGitCheckoutLocation = ( +const readDetachedContextId = ( gitDirectory: string, ): Effect.Effect => failsWithIdentity( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - return yield* fs.readFileString(gitCheckoutLocationPath(gitDirectory)).pipe( + return yield* fs.readFileString(gitDetachedContextIdentityPath(gitDirectory)).pipe( Effect.flatMap((content) => Effect.try({ - try: () => decodeLocation(content), + try: () => decodeDetachedContextId(content), catch: failsWith(InvalidManagedIdentityError), }), ), Effect.catchTag("PlatformError", (error) => error.reason._tag === "NotFound" ? Effect.succeed(undefined) - : Effect.fail( - new InvalidManagedIdentityError({ - message: `Git checkout location is inaccessible (${error.message})`, - }), - ), + : Effect.fail(inaccessibleIdentity("Detached context identity", error)), ), ); }), ); +export const readDetachedContextIdentity = readDetachedContextId; + +export const ensureDetachedContextIdentity = ( + gitDirectory: string, + idFactory: () => string = randomUUID, +): Effect.Effect< + { readonly contextId: string; readonly created: boolean }, + InvalidManagedIdentityError, + FileSystem.FileSystem +> => + failsWithIdentity( + Effect.gen(function* () { + const existing = yield* readDetachedContextId(gitDirectory); + if (existing !== undefined) return { contextId: existing, created: false }; + const contextId = createManagedUuid(idFactory, "contextId"); + const markerPath = gitDetachedContextIdentityPath(gitDirectory); + const outcome = yield* claimIdentityFile( + markerPath, + `${JSON.stringify({ version: DETACHED_CONTEXT_VERSION, contextId }, null, 2)}\n`, + "Detached context identity", + 0o600, + ); + if (outcome === "claimed") return { contextId, created: true }; + const winner = yield* readDetachedContextId(gitDirectory); + if (winner === undefined) { + return yield* Effect.fail( + new InvalidManagedIdentityError({ + message: "Detached context publication raced without a winning marker", + }), + ); + } + return { contextId: winner, created: false }; + }), + ); + const decodeLocation = (content: string): string => { let value: unknown; try { @@ -318,36 +287,71 @@ const decodeLocation = (content: string): string => { return Reflect.get(value, "workspacePath"); }; +export const readGitCheckoutLocation = ( + gitDirectory: string, +): Effect.Effect => + failsWithIdentity( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(gitCheckoutLocationPath(gitDirectory)).pipe( + Effect.flatMap((content) => + Effect.try({ + try: () => decodeLocation(content), + catch: failsWith(InvalidManagedIdentityError), + }), + ), + Effect.catchTag("PlatformError", (error) => + error.reason._tag === "NotFound" + ? Effect.succeed(undefined) + : Effect.fail(inaccessibleIdentity("Git checkout location", error)), + ), + ); + }), + ); + +const removeTemporary = (fs: FileSystem.FileSystem, path: string): Effect.Effect => + fs.remove(path, { force: true }).pipe(Effect.ignore); + +const writeTemporary = ( + fs: FileSystem.FileSystem, + path: string, + content: string, +): Effect.Effect => + Effect.scoped( + fs + .open(path, { flag: "wx", mode: 0o600 }) + .pipe(Effect.flatMap((file) => file.writeAll(new TextEncoder().encode(content)))), + ); + export const ensureGitCheckoutLocation = ( gitDirectory: string, workspacePath: string, ): Effect.Effect< { readonly workspacePath: string; readonly created: boolean }, - InvalidManagedIdentityError + InvalidManagedIdentityError, + FileSystem.FileSystem > => failsWithIdentity( - Effect.tryPromise({ - try: async () => { - const existing = await (async () => { - try { - return decodeLocation(await readFile(gitCheckoutLocationPath(gitDirectory), "utf8")); - } catch (error: unknown) { - if (errorCode(error) === "ENOENT") return undefined; - throw error; - } - })(); - if (existing !== undefined) return { workspacePath: existing, created: false }; - const markerPath = gitCheckoutLocationPath(gitDirectory); - const outcome = await claimFileAtomically( - markerPath, - `${JSON.stringify({ version: 1, workspacePath }, null, 2)}\n`, - { mode: 0o600 }, + Effect.gen(function* () { + const existing = yield* readGitCheckoutLocation(gitDirectory); + if (existing !== undefined) return { workspacePath: existing, created: false }; + const markerPath = gitCheckoutLocationPath(gitDirectory); + const outcome = yield* claimIdentityFile( + markerPath, + `${JSON.stringify({ version: 1, workspacePath }, null, 2)}\n`, + "Git checkout location", + 0o600, + ); + if (outcome === "claimed") return { workspacePath, created: true }; + const winner = yield* readGitCheckoutLocation(gitDirectory); + if (winner === undefined) { + return yield* Effect.fail( + new InvalidManagedIdentityError({ + message: "Checkout location publication raced without a winning marker", + }), ); - if (outcome === "claimed") return { workspacePath, created: true }; - const winner = decodeLocation(await readFile(markerPath, "utf8")); - return { workspacePath: winner, created: false }; - }, - catch: asRaised, + } + return { workspacePath: winner, created: false }; }), ); @@ -357,31 +361,34 @@ export const updateGitCheckoutLocationOwned = ( expectedPath: string, workspacePath: string, ownership: ControlOwnership, -): Effect.Effect<{ readonly workspacePath: string }, InvalidManagedIdentityError> => +): Effect.Effect< + { readonly workspacePath: string }, + InvalidManagedIdentityError, + FileSystem.FileSystem +> => failsWithIdentity( - Effect.tryPromise({ - try: async () => { - void ownership; - const markerPath = gitCheckoutLocationPath(gitDirectory); - const current = decodeLocation(await readFile(markerPath, "utf8")); - if (current !== expectedPath) { - throw new InvalidManagedIdentityError({ + Effect.gen(function* () { + void ownership; + const fs = yield* FileSystem.FileSystem; + const markerPath = gitCheckoutLocationPath(gitDirectory); + const current = yield* readGitCheckoutLocation(gitDirectory); + if (current === undefined || current !== expectedPath) { + return yield* Effect.fail( + new InvalidManagedIdentityError({ message: "Git checkout location changed before repair publication", - }); - } - const temporaryPath = `${markerPath}.tmp.${randomUUID()}`; - await writeFile( - temporaryPath, - `${JSON.stringify({ version: 1, workspacePath }, null, 2)}\n`, - { mode: 0o600 }, + }), ); - try { - await rename(temporaryPath, markerPath); - } finally { - await unlink(temporaryPath).catch(() => undefined); - } - return { workspacePath }; - }, - catch: asRaised, + } + const temporaryPath = `${markerPath}.tmp.${randomUUID()}`; + const publication = writeTemporary( + fs, + temporaryPath, + `${JSON.stringify({ version: 1, workspacePath }, null, 2)}\n`, + ).pipe(Effect.andThen(fs.rename(temporaryPath, markerPath))); + yield* Effect.ensuring( + publication, + Effect.uninterruptible(removeTemporary(fs, temporaryPath)), + ); + return { workspacePath }; }), ); 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..a8c4d7bb80 100644 --- a/packages/stack/src/managed/manager.ts +++ b/packages/stack/src/managed/manager.ts @@ -13,13 +13,8 @@ import { Scope, } from "effect"; import { isAbsolute, relative, resolve } from "node:path"; -import { PORT_CATALOG, type PortField, type PortSet } from "../PortCatalog.ts"; -import { - reservePortSet, - type PortAllocationError, - type PortLease, - type PortReservationRequest, -} from "../PortAllocator.ts"; +import { PORT_CATALOG, type PortSet } from "../PortCatalog.ts"; +import { reservePortSet, type PortLease, type PortReservationRequest } from "../PortAllocator.ts"; import { acquireControl, CONTROL_PORT_RANGE, @@ -65,7 +60,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"; @@ -100,7 +95,7 @@ export interface AllocateManagedPortsRequest { export interface ManagedPortAllocation { readonly assignments: ReadonlyArray; - readonly lease: ManagedPortLease; + readonly lease: PortLease; } interface ManagedStackStartResultBase { @@ -109,7 +104,7 @@ interface ManagedStackStartResultBase { export type ManagedStackStartResult = ManagedStackStartResultBase & { /** The lease remains live until the caller's Effect scope closes. */ - readonly lease: ManagedPortLease; + readonly lease: PortLease; }; export interface ManagedStackLifecycleUpdate { @@ -119,17 +114,12 @@ 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 { - readonly ports: PortSet; - readonly reserve: (fields: ReadonlyArray) => Effect.Effect; - readonly release: (fields: ReadonlyArray) => Effect.Effect; - readonly releaseAll: Effect.Effect; -} +export type ManagedPortLease = PortLease; export type ManagedDeleteResult = | { readonly outcome: "removed"; readonly stackId: string } @@ -240,7 +230,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, @@ -318,11 +308,29 @@ const stackDrift = ( }); }; -const portRequests = (plan: ManagedPortPlan): ReadonlyArray => +const portRequests = ( + plan: ManagedPortPlan, + automaticExcluded: ReadonlySet = new Set(), +): ReadonlyArray => [...plan.durable] - .sort((left, right) => Number(right.intent === "exact") - Number(left.intent === "exact")) - .map(({ field, selection }) => ({ field, selection })) - .concat(plan.runtimeOnly); + .sort( + (left, right) => + Number(right.selection.kind === "exact") - Number(left.selection.kind === "exact"), + ) + .map(({ field, selection }) => ({ + field, + selection: + selection.kind === "automatic" ? { ...selection, excluded: automaticExcluded } : selection, + })) + .concat( + plan.runtimeOnly.map(({ field, selection }) => ({ + field, + selection: + selection.kind === "automatic" + ? { ...selection, excluded: automaticExcluded } + : selection, + })), + ); const managedAssignments = ( plan: ManagedPortPlan, @@ -467,9 +475,6 @@ const makeManager = ( persisted, preferCatalogDefaults, }); - const requests = portRequests(plan); - const exactRequests = requests.filter((item) => item.selection.kind === "exact"); - const automaticRequests = requests.filter((item) => item.selection.kind === "automatic"); const invalidPersistedAutomatic = plan.durable.find( (entry) => entry.intent === "automatic" && @@ -533,6 +538,19 @@ const makeManager = ( for (const assignment of plan.inactiveAssignments) { strictReserved.add(assignment.port); } + const automaticExcluded = new Set(); + for (let port = CONTROL_PORT_RANGE.min; port <= CONTROL_PORT_RANGE.max; port += 1) { + automaticExcluded.add(port); + } + for (const port of strictReserved) automaticExcluded.add(port); + for (const [port] of owners) automaticExcluded.add(port); + for (const assignment of plan.inactiveAssignments) { + automaticExcluded.add(assignment.port); + } + for (const port of exactReserved) automaticExcluded.add(port); + + const requests = portRequests(plan, automaticExcluded); + const exactRequests = requests.filter((item) => item.selection.kind === "exact"); const requestedAssignments = exactRequests.flatMap((item) => { const entry = plan.durable.find((candidate) => candidate.field === item.field); if (entry?.selection.kind !== "exact") return []; @@ -544,6 +562,16 @@ const makeManager = ( } satisfies ManagedPortAssignment, ]; }); + for (const assignment of requestedAssignments) { + if (!exactReserved.has(assignment.port)) continue; + return yield* Effect.fail( + new ManagedExactPortOccupiedError({ + key: assignment.key, + port: assignment.port, + stackId: request.stackId, + }), + ); + } for (const assignment of requestedAssignments) { const owner = (owners.get(assignment.port) ?? []).find((candidate) => { const lifecycle = @@ -575,88 +603,29 @@ const makeManager = ( ); } } - const exactLease = - exactRequests.length === 0 - ? undefined - : yield* reservePortSet(exactRequests, { reserved: exactReserved }).pipe( - Effect.mapError((cause) => { - const entry = - cause.field === undefined - ? undefined - : plan.durable.find((candidate) => candidate.field === cause.field); - const key = - entry?.intent === "exact" ? PORT_CATALOG[entry.field].configKey : undefined; - return key !== undefined && cause.port !== undefined - ? new ManagedExactPortOccupiedError({ - key, - port: cause.port, - stackId: request.stackId, - }) - : new ManagedPortAllocationError({ - fields: exactRequests.map((item) => item.field), - cause, - }); - }), - ); - if (exactLease !== undefined) partialLeases.push(exactLease); - const automaticReserved = new Set(); - for (let port = CONTROL_PORT_RANGE.min; port <= CONTROL_PORT_RANGE.max; port += 1) { - automaticReserved.add(port); - } - for (const port of strictReserved) automaticReserved.add(port); - for (const [port] of owners) { - automaticReserved.add(port); - } - if (exactLease !== undefined) { - for (const port of Object.values(exactLease.ports)) { - if (port !== undefined) automaticReserved.add(port); - } - } - const automaticLease = - automaticRequests.length === 0 - ? undefined - : yield* reservePortSet(automaticRequests, { reserved: automaticReserved }).pipe( - Effect.mapError( - (cause) => - new ManagedPortAllocationError({ - fields: automaticRequests.map((item) => item.field), - cause, - }), - ), - ); - if (automaticLease !== undefined) partialLeases.push(automaticLease); - const ports: PortSet = { - ...exactLease?.ports, - ...automaticLease?.ports, - }; - const assignments = yield* managedAssignments(plan, ports); - const lease: ManagedPortLease = { - ports, - reserve: (fields) => - Effect.all( - [ - exactLease?.reserve( - fields.filter((field) => exactLease.ports[field] !== undefined), - ) ?? Effect.void, - automaticLease?.reserve( - fields.filter((field) => automaticLease.ports[field] !== undefined), - ) ?? Effect.void, - ], - { discard: true }, - ), - release: (fields) => - Effect.all( - [ - exactLease?.release(fields) ?? Effect.void, - automaticLease?.release(fields) ?? Effect.void, - ], - { discard: true }, - ), - releaseAll: Effect.all( - [exactLease?.releaseAll ?? Effect.void, automaticLease?.releaseAll ?? Effect.void], - { discard: true }, - ), - }; + const lease = yield* reservePortSet(requests, { reserved: exactReserved }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.mapError((cause) => { + const entry = + cause.field === undefined + ? undefined + : plan.durable.find((candidate) => candidate.field === cause.field); + const key = + entry?.intent === "exact" ? PORT_CATALOG[entry.field].configKey : undefined; + return key !== undefined && cause.port !== undefined + ? new ManagedExactPortOccupiedError({ + key, + port: cause.port, + stackId: request.stackId, + }) + : new ManagedPortAllocationError({ + fields: requests.map((item) => item.field), + cause, + }); + }), + ); + partialLeases.push(lease); + const assignments = yield* managedAssignments(plan, lease.ports); return { assignments, lease }; }); const guardedAttempt = Effect.exit(attempt).pipe( @@ -843,7 +812,7 @@ const makeManager = ( const updateLaunch = ( ownership: ControlOwnership, - update: ManagedStackLaunchUpdate, + update: ManagedStackLaunchUpdateRequest, ): Effect.Effect => lifecycleLock.withPermit( Effect.gen(function* () { @@ -852,9 +821,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); @@ -945,11 +935,13 @@ const makeManager = ( updatedAt, }); } - yield* updateGitCheckoutLocationOwned( - inspection.gitDirectory, - revalidated.expectedPath, - revalidated.path, - repairAcquisition, + yield* provideDependencies( + updateGitCheckoutLocationOwned( + inspection.gitDirectory, + revalidated.expectedPath, + revalidated.path, + repairAcquisition, + ), ); return yield* provideDependencies(discoverEnvironment(revalidated.path)); }), @@ -976,9 +968,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..839f944362 100644 --- a/packages/stack/src/node.ts +++ b/packages/stack/src/node.ts @@ -2,9 +2,13 @@ import { NodeServices } from "@effect/platform-node"; import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { BinaryResolver } from "./BinaryResolver.ts"; -import { createStack as createStackCore, type StackHandle } from "./createStack.ts"; +import { selectStackRuntime } from "./ContainerRuntime.ts"; +import { createStack as createStackCore, type ResolveConfigEffect } from "./createStack.ts"; +import { toStackHandle, type StackHandle } from "./stackHandle.ts"; +import { toStackError } from "./errors.ts"; import { prefetch as prefetchEffect, + type PrefetchEffectOptions, type PrefetchOptions, type PrefetchResult, } from "./prefetch.ts"; @@ -12,6 +16,10 @@ import { defaultCacheRoot } from "./paths.ts"; import { platformFactory } from "./platform-node.ts"; import { StackPreparation } from "./StackPreparation.ts"; import type { StackConfig } from "./StackConfig.ts"; +import { resolveConfig as resolveConfigEffect } from "./StackConfigResolver.ts"; + +const resolveConfigEffectForPlatform: ResolveConfigEffect = (config, options) => + resolveConfigEffect(config, options); /** * The Node daemon bootstrap is deliberately not exported from the package. The conditional Effect @@ -20,16 +28,35 @@ 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); + }); + const handle = await Effect.runPromise( + createStackCore(config, platformFactory, runtime, resolveConfigEffectForPlatform).pipe( + Effect.provide(NodeServices.layer), + ), + ); + return toStackHandle(handle); } 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/platform-bun.ts b/packages/stack/src/platform-bun.ts index ebbeee33fc..32fc9ecf5d 100644 --- a/packages/stack/src/platform-bun.ts +++ b/packages/stack/src/platform-bun.ts @@ -34,6 +34,10 @@ const controlTransport: ControlTransport["Service"] = { const server = yield* BunHttpServer.make({ hostname: endpoint.hostname, port: endpoint.port, + // Stack lifecycle requests can legitimately take up to the configured + // readiness deadline. Bun's 10-second default would otherwise close + // the control connection while the stack continues starting. + idleTimeout: 0, disablePreemptiveShutdown: true, routes: { [CONTROL_STATUS_PATH]: { @@ -79,17 +83,17 @@ const controlTransport: ControlTransport["Service"] = { ), read: (endpoint: ControlEndpoint) => Effect.tryPromise({ - try: async () => { - const response = await fetch(`http://127.0.0.1:${endpoint.port}${CONTROL_STATUS_PATH}`, { - signal: AbortSignal.timeout(500), + try: (signal) => + fetch(`http://127.0.0.1:${endpoint.port}${CONTROL_STATUS_PATH}`, { + signal: AbortSignal.any([signal, AbortSignal.timeout(500)]), // One-shot connection: a pooled keep-alive connection would let a // closed listener keep answering status probes while the probes // themselves keep the connection alive. headers: { connection: "close" }, - }); - if (!response.ok) throw new Error(`Control status request returned ${response.status}`); - return await response.json(); - }, + }).then((response) => { + if (!response.ok) throw new Error(`Control status request returned ${response.status}`); + return response.json(); + }), catch: (cause) => { if ( cause instanceof SyntaxError || @@ -107,14 +111,14 @@ const controlTransport: ControlTransport["Service"] = { }), requestStop: (endpoint: ControlEndpoint) => Effect.tryPromise({ - try: async () => { - const response = await fetch(`http://127.0.0.1:${endpoint.port}${CONTROL_STOP_PATH}`, { + try: (signal) => + fetch(`http://127.0.0.1:${endpoint.port}${CONTROL_STOP_PATH}`, { method: "POST", - signal: AbortSignal.timeout(500), + signal: AbortSignal.any([signal, AbortSignal.timeout(500)]), headers: { connection: "close" }, - }); - if (!response.ok) throw new Error(`Control stop request returned ${response.status}`); - }, + }).then((response) => { + if (!response.ok) throw new Error(`Control stop request returned ${response.status}`); + }), catch: (cause) => new ControlTransportError({ endpoint, reason: "unreachable", cause }), }), }; diff --git a/packages/stack/src/platform-node.integration.test.ts b/packages/stack/src/platform-node.integration.test.ts new file mode 100644 index 0000000000..f2965630f3 --- /dev/null +++ b/packages/stack/src/platform-node.integration.test.ts @@ -0,0 +1,154 @@ +import { Cause, Effect, Exit } from "effect"; +import { createServer, type Server } from "node:http"; +import type { Socket } from "node:net"; +import { describe, expect, test } from "vitest"; +import { + ControlProtocolError, + ControlTransport, + ControlTransportError, + type ControlEndpoint, +} from "./managed/control.ts"; +import { controlTransportLayer } from "./platform-node.ts"; + +const MAX_CONTROL_RESPONSE_BYTES = 64 * 1024; + +const listen = (server: Server): Promise => + new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("Expected TCP address")); + return; + } + resolve({ + hostname: "127.0.0.1", + port: address.port, + url: `http://127.0.0.1:${address.port}`, + }); + }); + }); + +const close = (server: Server, sockets: ReadonlySet): Promise => + new Promise((resolve, reject) => { + for (const socket of sockets) socket.destroy(); + if (!server.listening) { + resolve(); + return; + } + server.close((error) => (error === undefined ? resolve() : reject(error))); + }); + +const withTimeout = async (promise: Promise, timeoutMs = 5_000): Promise => { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +}; + +const runRead = (endpoint: ControlEndpoint) => + Effect.runPromise( + Effect.flatMap(ControlTransport, (transport) => transport.read(endpoint)).pipe( + Effect.provide(controlTransportLayer), + Effect.exit, + ), + ); + +const runStop = (endpoint: ControlEndpoint) => + Effect.runPromise( + Effect.flatMap(ControlTransport, (transport) => transport.requestStop(endpoint)).pipe( + Effect.provide(controlTransportLayer), + Effect.exit, + ), + ); + +const expectTypedFailure = ( + exit: Exit.Exit, + error: new (...args: any[]) => E, +) => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(error); +}; + +describe("Node control transport", () => { + test("maps post-header resets from owner and stop probes to typed failures", async () => { + let requestCount = 0; + let resolveRequest!: () => void; + let requestReady = Promise.resolve(); + const sockets = new Set(); + const server = createServer((_request, response) => { + sockets.add(response.socket!); + response.socket!.once("close", () => sockets.delete(response.socket!)); + resolveRequest(); + response.writeHead(200, { "content-type": "application/json" }); + response.flushHeaders(); + response.write(requestCount++ === 0 ? '{"protocolVersion":' : "{", () => response.destroy()); + }); + server.on("connection", (socket) => sockets.add(socket)); + try { + const endpoint = await listen(server); + + const prepareRequest = () => { + requestReady = new Promise((resolve) => { + resolveRequest = resolve; + }); + }; + + prepareRequest(); + const readExitPromise = runRead(endpoint); + await requestReady; + const readExit = await withTimeout(readExitPromise); + expectTypedFailure(readExit, ControlTransportError); + + prepareRequest(); + const stopExitPromise = runStop(endpoint); + await requestReady; + const stopExit = await withTimeout(stopExitPromise); + expectTypedFailure(stopExit, ControlTransportError); + } finally { + await close(server, sockets); + } + }); + + test("bounds an oversized owner status and closes the exact connection", async () => { + let resolveRequest!: () => void; + const requestReady = new Promise((resolve) => { + resolveRequest = resolve; + }); + let resolveClosed!: () => void; + const connectionClosed = new Promise((resolve) => { + resolveClosed = resolve; + }); + const sockets = new Set(); + const server = createServer((_request, response) => { + const socket = response.socket; + if (socket === null) throw new Error("Expected request socket"); + sockets.add(socket); + socket.once("close", () => { + sockets.delete(socket); + resolveClosed(); + }); + resolveRequest(); + response.writeHead(200, { "content-type": "application/json" }); + response.write("x".repeat(MAX_CONTROL_RESPONSE_BYTES + 1)); + }); + server.on("connection", (socket) => sockets.add(socket)); + try { + const endpoint = await listen(server); + const exitPromise = runRead(endpoint); + await requestReady; + const exit = await withTimeout(exitPromise); + expectTypedFailure(exit, ControlProtocolError); + await withTimeout(connectionClosed); + } finally { + await close(server, sockets); + } + }); +}); diff --git a/packages/stack/src/platform-node.ts b/packages/stack/src/platform-node.ts index 583db3c324..8cd3faa37c 100644 --- a/packages/stack/src/platform-node.ts +++ b/packages/stack/src/platform-node.ts @@ -15,6 +15,9 @@ import { type ControlOwnerStatus, type ControlEndpoint, } from "./managed/control.ts"; + +const MAX_CONTROL_RESPONSE_BYTES = 64 * 1024; + const errorCode = (cause: unknown): string | undefined => { if (typeof cause !== "object" || cause === null) return undefined; if ("code" in cause && typeof cause.code === "string") return cause.code; @@ -32,6 +35,35 @@ const closeControlServer = (server: Http.Server): Effect.Effect => return Effect.void; }); +const readError = ( + endpoint: ControlEndpoint, + cause: unknown, +): ControlTransportError | ControlProtocolError => { + const code = errorCode(cause); + if ( + code === "ECONNREFUSED" || + code === "ECONNRESET" || + code === "EHOSTUNREACH" || + (cause instanceof Error && cause.message === "Control status request timed out") + ) { + return new ControlTransportError({ endpoint, reason: "unreachable", cause }); + } + if ( + cause instanceof SyntaxError || + (cause instanceof Error && + cause.message === `Control status response exceeded ${MAX_CONTROL_RESPONSE_BYTES} bytes`) || + (cause instanceof Error && + cause.message.startsWith("Control status request returned") && + !cause.message.endsWith(" 404")) + ) { + return new ControlProtocolError({ endpoint, cause }); + } + if (cause instanceof Error && cause.message.endsWith(" 404")) { + return new ControlTransportError({ endpoint, reason: "unreachable", cause }); + } + return new ControlTransportError({ endpoint, reason: "transport", cause }); +}; + const controlTransport: ControlTransport["Service"] = { bind: (endpoint: ControlEndpoint, ownerStatus: () => ControlOwnerStatus, onStop: () => void) => { const rawServer = createServer((request, response) => { @@ -68,106 +100,202 @@ const controlTransport: ControlTransport["Service"] = { ); }, read: (endpoint: ControlEndpoint) => - Effect.tryPromise({ - try: async () => { - const requestStatus = (host: string) => - new Promise((resolve, reject) => { - const request = Http.request( - { - host, - port: endpoint.port, - path: CONTROL_STATUS_PATH, - method: "GET", - // One-shot connection: a pooled keep-alive connection would - // let a closed listener keep answering status probes while - // the probes themselves keep the connection alive. - agent: false, - }, - (response) => { - let body = ""; - response.setEncoding("utf8"); - response.on("data", (chunk: string) => { - body += chunk; - }); - response.on("end", () => { - if ((response.statusCode ?? 500) < 200 || (response.statusCode ?? 500) >= 300) { - reject( - new Error(`Control status request returned ${response.statusCode ?? 500}`), - ); - return; - } - try { - resolve(JSON.parse(body)); - } catch (cause) { - reject(cause); - } - }); - }, - ); - request.setTimeout(500, () => - request.destroy(new Error("Control status request timed out")), - ); - request.once("error", reject); - request.end(); - }); - return await requestStatus("127.0.0.1"); - }, - catch: (cause) => { - const code = errorCode(cause); - if ( - code === "ECONNREFUSED" || - code === "ECONNRESET" || - code === "EHOSTUNREACH" || - (cause instanceof Error && cause.message === "Control status request timed out") - ) { - return new ControlTransportError({ endpoint, reason: "unreachable", cause }); - } - if ( - cause instanceof SyntaxError || - (cause instanceof Error && - cause.message.startsWith("Control status request returned") && - !cause.message.endsWith(" 404")) - ) { - return new ControlProtocolError({ endpoint, cause }); - } - if (cause instanceof Error && cause.message.endsWith(" 404")) { - return new ControlTransportError({ endpoint, reason: "unreachable", cause }); + Effect.callback((resume) => { + let response: Http.IncomingMessage | undefined; + let onData: ((chunk: string) => void) | undefined; + let onEnd: (() => void) | undefined; + let onResponseError: ((cause: Error) => void) | undefined; + let onResponseAborted: (() => void) | undefined; + let onResponseClose: (() => void) | undefined; + let settled = false; + let cleanup = () => {}; + let dispose = () => {}; + const finish = (effect: Effect.Effect, shouldDispose = false) => { + if (settled) return; + settled = true; + cleanup(); + if (shouldDispose) dispose(); + resume(effect); + }; + const onRequestError = (cause: Error) => finish(Effect.fail(cause), true); + const request = Http.request( + { + host: "127.0.0.1", + port: endpoint.port, + path: CONTROL_STATUS_PATH, + method: "GET", + // One-shot connection: a pooled keep-alive connection would let a + // closed listener keep answering status probes while the probes + // themselves keep the connection alive. + agent: false, + }, + (incoming) => { + response = incoming; + let body = ""; + let bodyBytes = 0; + let ended = false; + let responseAborted = false; + onData = (chunk) => { + bodyBytes += Buffer.byteLength(chunk, "utf8"); + if (bodyBytes > MAX_CONTROL_RESPONSE_BYTES) { + finish( + Effect.fail( + new Error(`Control status response exceeded ${MAX_CONTROL_RESPONSE_BYTES} bytes`), + ), + true, + ); + return; + } + body += chunk; + }; + onEnd = () => { + ended = true; + if ((incoming.statusCode ?? 500) < 200 || (incoming.statusCode ?? 500) >= 300) { + finish( + Effect.fail( + new Error(`Control status request returned ${incoming.statusCode ?? 500}`), + ), + true, + ); + return; + } + try { + finish(Effect.succeed(JSON.parse(body))); + } catch (cause) { + finish(Effect.fail(cause), true); + } + }; + onResponseError = (cause) => finish(Effect.fail(cause), true); + onResponseAborted = () => { + responseAborted = true; + }; + onResponseClose = () => { + if (responseAborted || !ended) { + finish(Effect.fail(new Error("Control status response closed before end")), true); + } + }; + incoming.setEncoding("utf8"); + incoming.on("data", onData); + incoming.once("end", onEnd); + incoming.once("error", onResponseError); + incoming.once("aborted", onResponseAborted); + incoming.once("close", onResponseClose); + }, + ); + dispose = () => { + response?.destroy(); + request.destroy(); + }; + cleanup = () => { + request.removeListener("error", onRequestError); + request.setTimeout(0); + if (response !== undefined) { + if (onData !== undefined) response.removeListener("data", onData); + if (onEnd !== undefined) response.removeListener("end", onEnd); + if (onResponseError !== undefined) response.removeListener("error", onResponseError); + if (onResponseAborted !== undefined) { + response.removeListener("aborted", onResponseAborted); + } + if (onResponseClose !== undefined) response.removeListener("close", onResponseClose); } - return new ControlTransportError({ endpoint, reason: "transport", cause }); - }, - }), + }; + request.setTimeout(500, () => request.destroy(new Error("Control status request timed out"))); + request.once("error", onRequestError); + request.end(); + return Effect.sync(() => { + settled = true; + cleanup(); + dispose(); + }); + }).pipe(Effect.mapError((cause) => readError(endpoint, cause))), requestStop: (endpoint: ControlEndpoint) => - Effect.tryPromise({ - try: async () => { - await new Promise((resolve, reject) => { - const request = Http.request( - { - host: endpoint.hostname, - port: endpoint.port, - path: CONTROL_STOP_PATH, - method: "POST", - agent: false, - }, - (response) => { - response.resume(); - response.once("end", () => { - if ((response.statusCode ?? 500) >= 200 && (response.statusCode ?? 500) < 300) { - resolve(); - } else { - reject(new Error(`Control stop request returned ${response.statusCode ?? 500}`)); - } - }); - }, - ); - request.setTimeout(500, () => - request.destroy(new Error("Control stop request timed out")), - ); - request.once("error", reject); - request.end(); - }); - }, - catch: (cause) => new ControlTransportError({ endpoint, reason: "unreachable", cause }), - }), + Effect.callback((resume) => { + let response: Http.IncomingMessage | undefined; + let onEnd: (() => void) | undefined; + let onResponseError: ((cause: Error) => void) | undefined; + let onResponseAborted: (() => void) | undefined; + let onResponseClose: (() => void) | undefined; + let settled = false; + let cleanup = () => {}; + let dispose = () => {}; + const finish = (effect: Effect.Effect, shouldDispose = false) => { + if (settled) return; + settled = true; + cleanup(); + if (shouldDispose) dispose(); + resume(effect); + }; + const onRequestError = (cause: Error) => finish(Effect.fail(cause), true); + const request = Http.request( + { + host: endpoint.hostname, + port: endpoint.port, + path: CONTROL_STOP_PATH, + method: "POST", + agent: false, + }, + (incoming) => { + response = incoming; + let ended = false; + let responseAborted = false; + onEnd = () => { + ended = true; + if ((incoming.statusCode ?? 500) >= 200 && (incoming.statusCode ?? 500) < 300) { + finish(Effect.void); + } else { + finish( + Effect.fail( + new Error(`Control stop request returned ${incoming.statusCode ?? 500}`), + ), + true, + ); + } + }; + onResponseError = (cause) => finish(Effect.fail(cause), true); + onResponseAborted = () => { + responseAborted = true; + }; + onResponseClose = () => { + if (responseAborted || !ended) { + finish(Effect.fail(new Error("Control stop response closed before end")), true); + } + }; + incoming.once("end", onEnd); + incoming.once("error", onResponseError); + incoming.once("aborted", onResponseAborted); + incoming.once("close", onResponseClose); + incoming.resume(); + }, + ); + dispose = () => { + response?.destroy(); + request.destroy(); + }; + cleanup = () => { + request.removeListener("error", onRequestError); + request.setTimeout(0); + if (response !== undefined) { + if (onEnd !== undefined) response.removeListener("end", onEnd); + if (onResponseError !== undefined) response.removeListener("error", onResponseError); + if (onResponseAborted !== undefined) { + response.removeListener("aborted", onResponseAborted); + } + if (onResponseClose !== undefined) response.removeListener("close", onResponseClose); + } + }; + request.setTimeout(500, () => request.destroy(new Error("Control stop request timed out"))); + request.once("error", onRequestError); + request.end(); + return Effect.sync(() => { + settled = true; + cleanup(); + dispose(); + }); + }).pipe( + Effect.mapError( + (cause) => new ControlTransportError({ endpoint, reason: "unreachable", cause }), + ), + ), }; export const controlTransportLayer = Layer.succeed(ControlTransport, controlTransport); 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..90363a7797 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,16 @@ 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 () => { - const resolver = mockBinaryResolver({ failServices: ["auth"] }); + test("marks a Podman daemon disconnect on DockerPullError", async () => { + const resolver = mockBinaryResolver(); + // 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: ["not found"] }, + { exitCode: 1, stderr: ["Cannot connect to Podman"] }, ]); const layer = StackPreparation.layer.pipe( @@ -106,34 +96,123 @@ describe("prefetch", () => { Layer.provide(spawner.layer), ); + const error = await Effect.runPromise( + prefetch({ mode: "docker", containerRuntime: "podman", services: ["auth"] }).pipe( + Effect.provide(layer), + Effect.flip, + ), + ); + + expect(error).toBeInstanceOf(DockerPullError); + if (!(error instanceof DockerPullError)) throw error; + expect(error.daemonDown).toBe(true); + }); + + 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"], + prefetch({ mode: "docker", containerRuntime: "docker", services: ["postgrest"] }).pipe( + Effect.provide(layer), + ), + ); + + expect(Object.keys(result).sort()).toEqual(["postgres", "postgrest"]); + }); + + 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( + 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: defaultAuthDockerHubImage, + 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, defaultAuthEcrImage, defaultAuthDockerHubImage]); }); - 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("Docker preparation applies the catalog v prefix to bare versions", async () => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner([{ exitCode: 0 }]); const layer = StackPreparation.layer.pipe( Layer.provide(resolver.layer), Layer.provide(spawner.layer), @@ -142,163 +221,168 @@ describe("prefetch", () => { const result = await Effect.runPromise( prefetch({ mode: "docker", - services: ["auth"], + containerRuntime: "docker", + services: ["postgrest"], + versions: { postgrest: "16.1" }, }).pipe(Effect.provide(layer)), ); - expect(result.auth).toEqual({ + expect(result.postgrest).toEqual({ type: "docker", - image: defaultAuthGhcrImage, + image: "ghcr.io/supabase/cli/postgrest:v16.1", }); - 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..0e9b5a2a8a 100644 --- a/packages/stack/src/services/docker-cleanup.ts +++ b/packages/stack/src/services/docker-cleanup.ts @@ -1,11 +1,15 @@ 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 const dockerServiceCleanup = ( + runtime: ContainerRuntime, + containerName: string, +): Effect.Effect => Effect.sync(() => { try { - execFileSync("docker", ["rm", "-f", containerName], { + execFileSync(runtime, ["rm", "-f", containerName], { stdio: "ignore", timeout: 5_000, }); @@ -13,11 +17,12 @@ export const dockerServiceCleanup = (containerName: string): Effect.Effect }); export const dockerServiceOrphanCleanup = ( + runtime: ContainerRuntime, containerName: string, ): ReadonlyArray => [ { _tag: "RunCommand", - executable: "docker", + executable: runtime, args: ["rm", "-f", containerName], timeoutMs: 5_000, }, diff --git a/packages/stack/src/services/edge-runtime.ts b/packages/stack/src/services/edge-runtime.ts index 3938b7d5c0..6b1c6a9d70 100644 --- a/packages/stack/src/services/edge-runtime.ts +++ b/packages/stack/src/services/edge-runtime.ts @@ -1,9 +1,17 @@ -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { ServiceDef } from "@supabase/process-compose"; +import { Effect, FileSystem } from "effect"; import { dockerNetworkArgs } from "../Platform.ts"; +import { StackBuildError } from "../errors.ts"; import type { StackIdentity } from "../StackIdentity.ts"; -import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts"; +import { + dockerRunService, + hostUserForLinuxDocker, + 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"; import { edgeRuntimeNofileUlimit } from "./nofile-limit.ts"; @@ -18,29 +26,55 @@ interface EdgeRuntimeOptions { readonly dependencies: ReadonlyArray; } -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; + readonly bootstrapDir: string; } const bootstrapFileName = "index.ts"; const bootstrapMountDir = "/workspace"; -const bootstrapSourcePath = new URL("./edge-runtime-main.ts", import.meta.url); -const resolvedBootstrapSource = - bootstrapSource === "" ? readFileSync(bootstrapSourcePath, "utf8") : bootstrapSource; +const bootstrapSourcePath = fileURLToPath(new URL("./edge-runtime-main.ts", import.meta.url)); -function ensureBootstrapScript(runtimeRoot: string): string { - const bootstrapDir = join(runtimeRoot, "edge-runtime"); - mkdirSync(bootstrapDir, { recursive: true }); - const filePath = join(bootstrapDir, bootstrapFileName); - writeFileSync(filePath, resolvedBootstrapSource); - return bootstrapDir; -} +export const prepareEdgeRuntimeBootstrap = ( + runtimeRoot: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const bootstrapDir = join(runtimeRoot, "edge-runtime"); + yield* fs.makeDirectory(bootstrapDir, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to create the Edge Runtime bootstrap directory", + cause, + }), + ), + ); + const source = + bootstrapSource === "" + ? yield* fs.readFileString(bootstrapSourcePath).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to read the Edge Runtime bootstrap script", + cause, + }), + ), + ) + : bootstrapSource; + yield* fs.writeFileString(join(bootstrapDir, bootstrapFileName), source).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to write the Edge Runtime bootstrap script", + cause, + }), + ), + ); + return bootstrapDir; + }); const edgeRuntimeEnv = (opts: EdgeRuntimeOptions): Record => ({ ...opts.env, @@ -68,31 +102,15 @@ const edgeRuntimeHealthCheck = (port: number): ServiceDef["healthCheck"] => ...stackHealthBudgets.edgeRuntime, }); -export const makeEdgeRuntimeServiceNative = (opts: NativeEdgeRuntimeOptions): ServiceDef => { - const bootstrapDir = ensureBootstrapScript(opts.runtimeRoot); - - return { - name: "edge-runtime", - command: `${opts.binPath}/bin/edge-runtime`, - args: [...edgeRuntimeArgs(opts, bootstrapDir)], - env: edgeRuntimeEnv(opts), - dependencies: opts.dependencies, - healthCheck: edgeRuntimeHealthCheck(opts.port), - supervision: {}, - restart: "unless-stopped", - }; -}; - export const makeEdgeRuntimeServiceDocker = (opts: DockerEdgeRuntimeOptions): ServiceDef => { - const bootstrapDir = ensureBootstrapScript(opts.runtimeRoot); - return dockerRunService({ + runtime: opts.runtime, name: "edge-runtime", identity: opts.identity, image: opts.image, networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), volumes: [ - `${bootstrapDir}:${bootstrapMountDir}:ro`, + `${opts.bootstrapDir}:${bootstrapMountDir}:ro`, ...(opts.projectDir === undefined ? [] : [`${opts.projectDir}:${opts.projectDir}:ro`]), ], args: ["--ulimit", edgeRuntimeNofileUlimit(opts.platformOs).arg], @@ -100,6 +118,7 @@ export const makeEdgeRuntimeServiceDocker = (opts: DockerEdgeRuntimeOptions): Se ...edgeRuntimeEnv(opts), FUNCTIONS_RUNTIME_CONFIG_PATH: `${bootstrapMountDir}/functions-runtime-config.json`, }, + user: hostUserForLinuxDocker(opts.runtime, opts.platformOs), cmd: [...edgeRuntimeArgs(opts, bootstrapMountDir)], dependencies: opts.dependencies, healthCheck: edgeRuntimeHealthCheck(opts.port), 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..fbbf527499 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,13 +13,22 @@ 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 * tables/sequences/functions in `public` owned by `postgres` are not reachable via the Data API * roles without explicit GRANTs. */ -export const REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL = ` +const REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL = ` alter default privileges for role postgres in schema public revoke select, insert, update, delete on tables from anon, authenticated, service_role; alter default privileges for role postgres in schema public @@ -27,40 +37,110 @@ 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", + "-e", + "JWT_SECRET", + "-e", + "JWT_EXP", + 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`, + ], + env: { + PGPASSWORD: "postgres", + JWT_SECRET: opts.jwtSecret, + JWT_EXP: String(opts.jwtExpiry), + }, + dependencies: opts.dependencies, + supervision: {}, + restart: "no", +}); + export const makePostgresInitService = (opts: PostgresInitOptions): ServiceDef => { const pgBinDir = `${opts.postgresDir}/bin`; const pgLibDir = `${opts.postgresDir}/lib`; const migrationsDir = `${opts.postgresDir}/share/supabase-cli/migrations`; - const psql = `${pgBinDir}/psql -h 127.0.0.1 -p ${opts.dbPort}`; - const psqlOpts = `-v ON_ERROR_STOP=1 --no-password --no-psqlrc`; - - const revokeStep = opts.autoExposeNewTables - ? "" - : ` - # Revoke default privileges for the Data API roles on schema public so new tables - # require explicit GRANTs. Mirrors Studio's behaviour at cloud project creation. - ${psql} ${psqlOpts} -U postgres -d postgres <<'EOSQL' -${REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL} -EOSQL -`; + // Keep executable and SQL-file paths in arrays so cache roots containing + // whitespace remain single argv entries all the way to psql. + const psqlPath = `${pgBinDir}/psql`; + const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; + const psqlArray = `psql=(${shellQuote(psqlPath)} -h 127.0.0.1 -p ${opts.dbPort})`; // Replaces calling migrate.sh (which spawns ~57 separate psql processes) with // chained -f flags that run all SQL files in a single psql session, cutting // postgres-init time from ~5s to ~1s. const script = ` +set -e export PATH="${pgBinDir}:$PATH" export PGPASSWORD=postgres db="${migrationsDir}" +${psqlArray} +psql_opts=(-v ON_ERROR_STOP=1 --no-password --no-psqlrc) + +init_completion_sql=$(cat <<'EOSQL' +ALTER USER supabase_admin WITH PASSWORD 'postgres'; +CREATE SCHEMA IF NOT EXISTS supabase_migrations; +CREATE TABLE IF NOT EXISTS supabase_migrations.cli_init ( + phase text PRIMARY KEY, + completed_at timestamptz NOT NULL DEFAULT now() +); +INSERT INTO supabase_migrations.cli_init (phase) +VALUES ('init') +ON CONFLICT (phase) DO NOTHING; +EOSQL +) -# Check if already migrated (authenticator role created by initial-schema.sql) -if ${psql} -U supabase_admin -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='authenticator'" 2>/dev/null | grep -q 1; then - echo "Database already initialized, updating passwords..." +migration_completion_sql=$(cat <<'EOSQL' +${opts.autoExposeNewTables ? "" : REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL} +INSERT INTO supabase_migrations.cli_init (phase) +VALUES ('complete') +ON CONFLICT (phase) DO UPDATE SET completed_at = EXCLUDED.completed_at; +EOSQL +) + +# The init phase is committed independently so a failed migration phase can +# resume without replaying non-idempotent bundled init scripts. +if "\${psql[@]}" -U supabase_admin -d postgres -tAc "SELECT 1 FROM supabase_migrations.cli_init WHERE phase = 'init'" 2>/dev/null | grep -q 1; then + echo "Database initial schema already initialized" else echo "Running Supabase migrations..." # Create postgres role if missing (as supabase_admin) - ${psql} ${psqlOpts} -U supabase_admin -d postgres <<'EOSQL' + "\${psql[@]}" "\${psql_opts[@]}" -U supabase_admin -d postgres <<'EOSQL' do $$ begin if not exists (select from pg_roles where rolname = 'postgres') then @@ -71,41 +151,40 @@ end $$ EOSQL # Run all init-scripts in a single psql session (as postgres) - init_flags="" + init_flags=() for sql in "$db"/init-scripts/*.sql; do - [ -f "$sql" ] && init_flags="$init_flags -f $sql" + [ -f "$sql" ] && init_flags+=( -f "$sql" ) done - if [ -n "$init_flags" ]; then - ${psql} ${psqlOpts} -U postgres -d postgres $init_flags - fi + "\${psql[@]}" "\${psql_opts[@]}" --single-transaction -U postgres -d postgres "\${init_flags[@]}" -c "$init_completion_sql" +fi - # Set supabase_admin password (as postgres) - ${psql} ${psqlOpts} -U postgres -d postgres -c "ALTER USER supabase_admin WITH PASSWORD 'postgres'" +if "\${psql[@]}" -U supabase_admin -d postgres -tAc "SELECT 1 FROM supabase_migrations.cli_init WHERE phase = 'complete'" 2>/dev/null | grep -q 1; then + echo "Database migrations already initialized" +else + echo "Running Supabase migrations..." # Run all migrations in a single psql session (as supabase_admin) - migrate_flags="" + migrate_flags=() for sql in "$db"/migrations/*.sql; do - [ -f "$sql" ] && migrate_flags="$migrate_flags -f $sql" + [ -f "$sql" ] && migrate_flags+=( -f "$sql" ) done - if [ -n "$migrate_flags" ]; then - ${psql} ${psqlOpts} -U supabase_admin -d postgres $migrate_flags - fi + "\${psql[@]}" "\${psql_opts[@]}" --single-transaction -U supabase_admin -d postgres "\${migrate_flags[@]}" -c "$migration_completion_sql" # Reset stats (non-fatal, matches migrate.sh) - ${psql} ${psqlOpts} -U supabase_admin -d postgres -c 'SELECT extensions.pg_stat_statements_reset(); SELECT pg_stat_reset();' || true -${revokeStep}fi + "\${psql[@]}" "\${psql_opts[@]}" -U supabase_admin -d postgres -c 'SELECT extensions.pg_stat_statements_reset(); SELECT pg_stat_reset();' || true +fi # Backfill schemas/databases used by docker-backed auxiliary services. -${psql} ${psqlOpts} -U postgres -d postgres <<'EOSQL' +"\${psql[@]}" "\${psql_opts[@]}" -U postgres -d postgres <<'EOSQL' CREATE SCHEMA IF NOT EXISTS _realtime; ALTER SCHEMA _realtime OWNER TO postgres; EOSQL -if ! ${psql} -U postgres -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = '_supabase'" 2>/dev/null | grep -q 1; then - ${psql} ${psqlOpts} -U postgres -d postgres -c "CREATE DATABASE _supabase WITH OWNER postgres" +if ! "\${psql[@]}" -U postgres -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = '_supabase'" 2>/dev/null | grep -q 1; then + "\${psql[@]}" "\${psql_opts[@]}" -U postgres -d postgres -c "CREATE DATABASE _supabase WITH OWNER postgres" fi -${psql} ${psqlOpts} -U postgres -d _supabase <<'EOSQL' +"\${psql[@]}" "\${psql_opts[@]}" -U postgres -d _supabase <<'EOSQL' CREATE SCHEMA IF NOT EXISTS _analytics; ALTER SCHEMA _analytics OWNER TO postgres; CREATE SCHEMA IF NOT EXISTS _supavisor; @@ -113,7 +192,7 @@ ALTER SCHEMA _supavisor OWNER TO postgres; EOSQL # Always update role passwords (idempotent) -${psql} -U supabase_admin -d postgres -c " +"\${psql[@]}" -U supabase_admin -d postgres -c " DO \\$\\$ DECLARE roles text[] := ARRAY['authenticator','supabase_auth_admin','supabase_storage_admin','supabase_functions_admin','supabase_replication_admin','supabase_read_only_user','postgres']; diff --git a/packages/stack/src/services/postgres.ts b/packages/stack/src/services/postgres.ts index 910b76d544..a3c99216f9 100644 --- a/packages/stack/src/services/postgres.ts +++ b/packages/stack/src/services/postgres.ts @@ -1,4 +1,3 @@ -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"; @@ -7,6 +6,8 @@ import { stackHealthBudgets } from "./health-budgets.ts"; import { dockerExecHealthCheck, dockerRunService, + hostUserForLinuxDocker, + type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; @@ -19,15 +20,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 +37,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", @@ -58,31 +51,8 @@ const NATIVE_POSTGRES_RUNTIME_ARGS = [ 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 postgresGetKeyScript = (binPath: string): string => + `${binPath}/share/supabase-cli/config/pgsodium_getkey.sh`; const postgresHealthCheck = (binPath: string, port: number) => ({ probe: { @@ -98,71 +68,54 @@ 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", + // Linux /proc/1/comm truncates `.postgres-wrapped` to 15 characters. + `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 => { + // The bundle path is a private, scope-owned alias prepared by StackBuilder. + // It intentionally does not persist between handles: the initializer only + // needs a no-space path while this service definition is active. const initScript = `${opts.binPath}/share/supabase-cli/bin/supabase-postgres-init.sh`; - - 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 getKeyScript = postgresGetKeyScript(opts.binPath); return { name: "postgres", - command: "bash", - args: [initScript, "-p", String(opts.port), ...NATIVE_POSTGRES_RUNTIME_ARGS], + command: initScript, + args: [ + "-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,19 +126,59 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => }; export const makePostgresServiceDocker = (opts: DockerPostgresOptions): ServiceDef => { - const env = postgresDockerEnv(opts); const containerName = dockerContainerName("postgres", opts.identity.key); + const hostUser = hostUserForLinuxDocker(opts.runtime, opts.platformOs); + const [hostUid, hostGid] = hostUser?.split(":") ?? []; + 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 => + hostUser === undefined + ? `exec /usr/local/bin/entry.sh ${args}` + : `exec busybox su -s /usr/bin/sh supabase_cli -c "exec /usr/local/bin/entry.sh ${args}"`; + // initdb requires the effective uid to resolve through /etc/passwd, while + // the image init script chmods its key helper. Perform only that image setup + // as root, then drop to the host uid before touching the bind-mounted data. + const hostUserSetup = + hostUser === undefined + ? "" + : `printf 'supabase_cli:x:${hostUid}:${hostGid}:Supabase CLI:/tmp:/usr/bin/sh\\n' >> /etc/passwd +busybox chown ${hostUid}:${hostGid} /var/lib/postgresql/data +busybox chown ${hostUid}:${hostGid} /opt/postgres/share/supabase-cli/config/pgsodium_getkey.sh +`; + const command = `${hostUserSetup}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 + ${hostUser === undefined ? "" : `busybox chown ${hostUid}:${hostGid} /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" }, + user: hostUser === undefined ? undefined : "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), }); 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..87c3112a4a 100644 --- a/packages/stack/src/services/service-utils.ts +++ b/packages/stack/src/services/service-utils.ts @@ -1,5 +1,6 @@ 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"; @@ -8,7 +9,11 @@ export interface ServiceDependency { 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,6 +23,8 @@ 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"]; @@ -25,6 +32,18 @@ interface DockerRunServiceOptions { readonly orphanCleanup?: ReadonlyArray; } +export const hostUserForLinuxDocker = ( + runtime: ContainerRuntime, + platformOs: string, +): string | undefined => { + // Linux bind mounts preserve numeric ownership. Matching the caller keeps + // private runtime files readable and persistent data removable by the host. + if (runtime !== "docker" || platformOs !== "linux") return undefined; + const uid = process.getuid?.(); + const gid = process.getgid?.(); + return uid === undefined || gid === undefined ? undefined : `${uid}:${gid}`; +}; + const envArgs = (env: Record): ReadonlyArray => Object.entries(env).flatMap(([key, value]) => ["-e", `${key}=${value}`]); @@ -44,6 +63,7 @@ export const hostHttpHealthCheck = ( }); export const dockerExecHealthCheck = ( + runtime: ContainerRuntime, containerName: string, command: string, args: ReadonlyArray, @@ -51,7 +71,7 @@ export const dockerExecHealthCheck = ( ): ServiceDef["healthCheck"] => ({ probe: { _tag: "Exec", - command: "docker", + command: runtime, args: ["exec", containerName, command, ...args], }, ...opts, @@ -71,6 +91,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 +102,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), supervision: { - orphanCleanup: [...dockerServiceOrphanCleanup(containerName), ...(opts.orphanCleanup ?? [])], + orphanCleanup: [ + ...dockerServiceOrphanCleanup(opts.runtime, containerName), + ...(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 ca84c3b279..1ad2bb60a5 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -1,18 +1,15 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { analyticsDockerRuntimeNetwork, makeAnalyticsServiceDocker } from "./analytics.ts"; import { makeAuthServiceNative, makeAuthServiceDocker } from "./auth.ts"; -import { makeEdgeRuntimeServiceDocker, makeEdgeRuntimeServiceNative } from "./edge-runtime.ts"; +import { makeEdgeRuntimeServiceDocker } from "./edge-runtime.ts"; import { edgeRuntimeNofileUlimit } from "./nofile-limit.ts"; import { makeImgproxyServiceDocker } from "./imgproxy.ts"; import { makeMailpitServiceDocker } from "./mailpit.ts"; import { makePgmetaServiceDocker } from "./pgmeta.ts"; -import { - makePostgresInitService, - REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL, -} from "./postgres-init.ts"; +import { makePostgresInitService } from "./postgres-init.ts"; import { makePostgresService, makePostgresServiceDocker } from "./postgres.ts"; import { makePostgrestService, makePostgrestServiceDocker } from "./postgrest.ts"; import { makeRealtimeServiceDocker } from "./realtime.ts"; @@ -36,7 +33,6 @@ const EPHEMERAL_IDENTITY: StackIdentity = stackIdentity({ apiPort: API_PORT }); const POSTGRES_BIN_PATH = `/cache/postgres/${DEFAULT_VERSIONS.postgres}/darwin-arm64`; const POSTGREST_BIN_PATH = `/cache/postgrest/${DEFAULT_VERSIONS.postgrest}/macos-aarch64`; const AUTH_BIN_PATH = `/cache/auth/${DEFAULT_VERSIONS.auth}/arm64`; -const EDGE_RUNTIME_BIN_PATH = `/cache/edge-runtime/${DEFAULT_VERSIONS["edge-runtime"]}/aarch64-darwin`; describe("makePostgresService", () => { it("creates a postgres ServiceDef with correct defaults", () => { const def = makePostgresService({ @@ -47,18 +43,10 @@ describe("makePostgresService", () => { }); expect(def.name).toBe("postgres"); - expect(def.command).toBe("bash"); - expect(def.args).toEqual([ + expect(def.command).toBe( `${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.env?.PGDATA).toBe("/tmp/supabase/data"); expect(def.env?.POSTGRES_PASSWORD).toBe("postgres"); expect(def.env?.DYLD_LIBRARY_PATH).toBe(`${POSTGRES_BIN_PATH}/lib`); @@ -96,6 +84,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, @@ -126,60 +115,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: [], }); @@ -193,56 +136,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(); }); }); @@ -260,7 +166,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`, ); @@ -281,6 +187,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", @@ -325,7 +232,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); @@ -344,6 +251,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, @@ -384,9 +292,11 @@ describe("makeEdgeRuntimeServiceDocker", () => { try { const def = makeEdgeRuntimeServiceDocker({ + runtime: "docker", image: dockerImageForService("edge-runtime", DEFAULT_VERSIONS["edge-runtime"]), identity: EPHEMERAL_IDENTITY, runtimeRoot: tempDir, + bootstrapDir: path.join(tempDir, "edge-runtime"), port: 54340, inspectorPort: 54341, policy: "per_worker", @@ -396,9 +306,6 @@ describe("makeEdgeRuntimeServiceDocker", () => { }); const bootstrapDir = path.join(tempDir, "edge-runtime"); - const bootstrapPath = path.join(bootstrapDir, "index.ts"); - expect(readFileSync(bootstrapPath, "utf8")).toContain("FUNCTIONS_NOT_CONFIGURED"); - expect(readFileSync(bootstrapPath, "utf8")).toContain("/_internal/health"); expect(def.name).toBe("edge-runtime"); expect(def.command).toBe("docker"); expect(def.args).toContain(`supabase-edge-runtime-${API_PORT}`); @@ -423,46 +330,6 @@ describe("makeEdgeRuntimeServiceDocker", () => { }); }); -describe("makeEdgeRuntimeServiceNative", () => { - it("creates a native edge runtime service with a generated bootstrap script", () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "stack-edge-runtime-native-")); - - try { - const def = makeEdgeRuntimeServiceNative({ - binPath: EDGE_RUNTIME_BIN_PATH, - runtimeRoot: tempDir, - port: 54340, - inspectorPort: 54341, - policy: "per_worker", - env: { SUPABASE_INTERNAL_DEBUG: "true" }, - dependencies: [{ service: "postgres-init", condition: "completed" }], - }); - - const bootstrapPath = path.join(tempDir, "edge-runtime", "index.ts"); - expect(readFileSync(bootstrapPath, "utf8")).toContain("FUNCTIONS_NOT_CONFIGURED"); - expect(readFileSync(bootstrapPath, "utf8")).toContain("/_internal/health"); - expect(def.name).toBe("edge-runtime"); - expect(def.command).toBe(`${EDGE_RUNTIME_BIN_PATH}/bin/edge-runtime`); - expect(def.args).toContain("start"); - expect(def.args).toContain(`--main-service=${path.join(tempDir, "edge-runtime")}`); - expect(def.args).toContain(`--port=54340`); - expect(def.args).toContain(`--policy=per_worker`); - expect(def.env?.EDGE_RUNTIME_INSPECTOR_PORT).toBe("54341"); - expect(def.dependencies).toEqual([{ service: "postgres-init", condition: "completed" }]); - expect(def.healthCheck?.probe).toEqual({ - _tag: "Http", - host: "127.0.0.1", - port: 54340, - path: "/_internal/health", - scheme: "http", - }); - expect(def.supervision).toEqual({}); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }); -}); - describe("makePostgresInitService", () => { it("creates a one-shot postgres-init ServiceDef", () => { const def = makePostgresInitService({ @@ -482,95 +349,13 @@ describe("makePostgresInitService", () => { expect(def.env?.LD_LIBRARY_PATH).toBe(`${POSTGRES_BIN_PATH}/lib`); expect(def.supervision).toBeDefined(); }); - - it("does not use set -e (matches Go template approach)", () => { - const def = makePostgresInitService({ - postgresDir: POSTGRES_BIN_PATH, - dbPort: DB_PORT, - autoExposeNewTables: true, - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - const script = def.args?.[1] as string; - expect(script).not.toContain("set -e"); - }); - - it("includes idempotency check for authenticator role", () => { - const def = makePostgresInitService({ - postgresDir: "/cache/postgres/17/darwin-arm64", - dbPort: DB_PORT, - autoExposeNewTables: true, - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - const script = def.args?.[1] as string; - expect(script).toContain("authenticator"); - expect(script).toContain("already initialized"); - }); - - it("backfills auxiliary service schemas and internal databases", () => { - const def = makePostgresInitService({ - postgresDir: "/cache/postgres/17/darwin-arm64", - dbPort: DB_PORT, - autoExposeNewTables: true, - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - const script = def.args?.[1] as string; - - expect(script).toContain("CREATE SCHEMA IF NOT EXISTS _realtime"); - expect(script).toContain("SELECT 1 FROM pg_database WHERE datname = '_supabase'"); - expect(script).toContain("CREATE DATABASE _supabase WITH OWNER postgres"); - expect(script).toContain("CREATE SCHEMA IF NOT EXISTS _analytics"); - expect(script).toContain("CREATE SCHEMA IF NOT EXISTS _supavisor"); - }); - - it("batches SQL files via chained -f flags instead of shelling out to migrate.sh", () => { - const def = makePostgresInitService({ - postgresDir: "/cache/postgres/17/darwin-arm64", - dbPort: DB_PORT, - autoExposeNewTables: true, - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - const script = def.args?.[1] as string; - expect(script).not.toMatch(/sh .+migrate\.sh/); - expect(script).toContain("-f $sql"); - expect(script).toContain("init-scripts/*.sql"); - expect(script).toContain("migrations/*.sql"); - }); - - it("does not revoke default Data API privileges when autoExposeNewTables is true", () => { - const def = makePostgresInitService({ - postgresDir: POSTGRES_BIN_PATH, - dbPort: DB_PORT, - autoExposeNewTables: true, - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - const script = def.args?.[1] as string; - expect(script).not.toContain("alter default privileges"); - expect(script).not.toContain("revoke select, insert, update, delete on tables"); - }); - - it("revokes default Data API privileges on `public` when autoExposeNewTables is false", () => { - const def = makePostgresInitService({ - postgresDir: POSTGRES_BIN_PATH, - dbPort: DB_PORT, - autoExposeNewTables: false, - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - const script = def.args?.[1] as string; - expect(script).toContain(REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL); - expect(script).toContain( - "revoke select, insert, update, delete on tables from anon, authenticated, service_role", - ); - expect(script).toContain( - "revoke usage, select on sequences from anon, authenticated, service_role", - ); - expect(script).toContain("revoke execute on functions from anon, authenticated, service_role"); - }); }); 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, @@ -598,6 +383,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, @@ -634,6 +420,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, @@ -654,6 +441,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, @@ -674,6 +462,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, @@ -694,6 +483,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", @@ -718,6 +508,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, @@ -742,13 +533,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, @@ -769,6 +558,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/stackHandle.ts b/packages/stack/src/stackHandle.ts new file mode 100644 index 0000000000..2f94fbce68 --- /dev/null +++ b/packages/stack/src/stackHandle.ts @@ -0,0 +1,55 @@ +import type { LogEntry } from "@supabase/process-compose"; +import { Effect, Stream } from "effect"; +import type { FunctionsReloadConfig } from "./functions.ts"; +import type { ForegroundStackHandle } from "./createStack.ts"; +import type { EdgeRuntimeReloadConfig } from "./Stack.ts"; +import type { ReadyOptions } from "./StackConfig.ts"; +import type { StackServiceState } from "./StackServiceState.ts"; + +/** Public Promise/AsyncIterable stack surface for Node and Bun consumers. */ +export interface StackHandle extends AsyncDisposable { + readonly url: string; + readonly dbUrl: string; + readonly publishableKey: string; + readonly secretKey: string; + start(): Promise; + stop(): Promise; + dispose(): Promise; + startService(name: string): Promise; + stopService(name: string): Promise; + restartService(name: string): Promise; + reloadFunctions(opts?: FunctionsReloadConfig): Promise; + reloadEdgeRuntime(opts: EdgeRuntimeReloadConfig): Promise; + ready(opts?: ReadyOptions): Promise; + serviceReady(name: string, opts?: ReadyOptions): Promise; + getStatus(): Promise>; + getServiceStatus(name: string): Promise; + statusChanges(): AsyncIterable; + logs(): AsyncIterable; + serviceLogs(name: string): AsyncIterable; + logHistory(name: string, limit?: number): Promise>; +} + +export const toStackHandle = (handle: ForegroundStackHandle): StackHandle => ({ + url: handle.url, + dbUrl: handle.dbUrl, + publishableKey: handle.publishableKey, + secretKey: handle.secretKey, + start: () => Effect.runPromise(handle.start()), + stop: () => Effect.runPromise(handle.stop()), + dispose: () => Effect.runPromise(handle.dispose()), + startService: (name) => Effect.runPromise(handle.startService(name)), + stopService: (name) => Effect.runPromise(handle.stopService(name)), + restartService: (name) => Effect.runPromise(handle.restartService(name)), + reloadFunctions: (opts) => Effect.runPromise(handle.reloadFunctions(opts)), + reloadEdgeRuntime: (opts) => Effect.runPromise(handle.reloadEdgeRuntime(opts)), + ready: (opts) => Effect.runPromise(handle.ready(opts)), + serviceReady: (name, opts) => Effect.runPromise(handle.serviceReady(name, opts)), + getStatus: () => Effect.runPromise(handle.getStatus()), + getServiceStatus: (name) => Effect.runPromise(handle.getServiceStatus(name)), + statusChanges: () => Stream.toAsyncIterable(handle.statusChanges()), + logs: () => Stream.toAsyncIterable(handle.logs()), + serviceLogs: (name) => Stream.toAsyncIterable(handle.serviceLogs(name)), + logHistory: (name, limit) => Effect.runPromise(handle.logHistory(name, limit)), + [Symbol.asyncDispose]: () => Effect.runPromise(handle.dispose()), +}); diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts index e8b0e28cd7..3c5bbf41b0 100644 --- a/packages/stack/src/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor.integration.test.ts @@ -26,7 +26,7 @@ import { RemoteStack } from "./RemoteStack.ts"; import { httpTransportClientLayer } from "./HttpTransportClient.ts"; import { managedDaemonLayer } from "./supervisor.ts"; import { managedStackDocumentPath, managedStackPaths } from "./managed/paths.ts"; -import { resolveConfig } from "./StackConfigResolver.ts"; +import { resolveConfig as resolveConfigEffect } from "./StackConfigResolver.ts"; import { controlEndpoint, type ControlEndpoint } from "./managed/control.ts"; import { deriveStackId, type EnvironmentIdentity } from "./managed/environment.ts"; import type { SupervisorStartMessage, SupervisorStartedMessage } from "./supervisor.ts"; @@ -41,12 +41,16 @@ const errorChildEntryPoint = fileURLToPath( const bunExecutable = process.env["BUN_EXECUTABLE"] ?? "bun"; const FILE_WAIT_TIMEOUT_MS = 30_000; +const resolveConfig = (...args: Parameters) => + Effect.runPromise(resolveConfigEffect(...args).pipe(Effect.provide(NodeFileSystem.layer))); + type TestMode = "bind-all" | "fail-after-bind" | "hold-reservations" | "hold-start" | "hold-stop"; interface ChildHandle { readonly child: ChildProcess; readonly started: Promise; readonly attachedBeforeReady: Promise; + readonly managedStarted: Promise; } const workspace = async (): Promise<{ @@ -253,41 +257,45 @@ const spawnChild = ( child.once("error", onError); child.once("exit", onExit); }); - const attachedBeforeReady = new Promise((resolve, reject) => { - const onMessage = (value: unknown) => { - if ( - typeof value === "object" && - value !== null && - "type" in value && - value.type === "test-stage" && - "stage" in value && - value.stage === "attached-before-ready" - ) { + const waitForStage = (stage: "attached-before-ready" | "managed-started") => + new Promise((resolve, reject) => { + const onMessage = (value: unknown) => { + if ( + typeof value === "object" && + value !== null && + "type" in value && + value.type === "test-stage" && + "stage" in value && + value.stage === stage + ) { + cleanup(); + resolve(); + } + }; + const cleanup = () => { + child.off("message", onMessage); + child.off("error", onError); + child.off("exit", onExit); + }; + const onError = (cause: Error) => { cleanup(); - resolve(); - } - }; - const cleanup = () => { - child.off("message", onMessage); - child.off("error", onError); - child.off("exit", onExit); - }; - const onError = (cause: Error) => { - cleanup(); - reject(cause); - }; - const onExit = (code: number | null) => { - cleanup(); - reject(new Error(`supervisor exited before attach wait stage (${String(code)})`)); - }; - child.on("message", onMessage); - child.once("error", onError); - child.once("exit", onExit); - }); + reject(cause); + }; + const onExit = (code: number | null) => { + cleanup(); + reject(new Error(`supervisor exited before ${stage} stage (${String(code)})`)); + }; + child.on("message", onMessage); + child.once("error", onError); + child.once("exit", onExit); + }); + const attachedBeforeReady = waitForStage("attached-before-ready"); + const managedStarted = waitForStage("managed-started"); void started.catch(() => undefined); void attachedBeforeReady.catch(() => undefined); + void managedStarted.catch(() => undefined); child.send(input); - return { child, started, attachedBeforeReady }; + return { child, started, attachedBeforeReady, managedStarted }; }; const kill = (child: ChildProcess): Promise => @@ -342,7 +350,6 @@ const remoteInfo = (endpoint: ControlEndpoint): Promise<{ readonly url: string } const updateLaunch = async ( endpoint: ControlEndpoint, launch: { - readonly mode: "native" | "auto" | "docker"; readonly versions: Record; }, ): Promise => { @@ -384,24 +391,21 @@ const bindFakeOwner = async ( endpoint: ControlEndpoint, makeServer: () => ReturnType, ): Promise> => { - const deadline = Date.now() + 10_000; - do { - const server = makeServer(); - try { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(endpoint.port, endpoint.hostname, () => { - server.off("error", reject); - resolve(); - }); - }); - return server; - } catch { - server.close(); - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } while (Date.now() < deadline); - throw new Error(`timed out binding fake owner at ${endpoint.url}`); + const server = makeServer(); + await new Promise((resolve, reject) => { + const onError = (cause: Error) => { + server.off("listening", onListening); + reject(new Error(`unable to bind fake owner at ${endpoint.url}: ${cause.message}`)); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(endpoint.port, endpoint.hostname); + }); + return server; }; const listenStartingOwner = ( @@ -497,8 +501,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 +517,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; @@ -634,7 +642,7 @@ describe("detached supervisor child journeys", () => { { stackRoot: paths.root, runtimeRoot: paths.runtime, - portAllocator: () => Effect.succeed({ apiPort: 55001, dbPort: 55002 }), + ports: { apiPort: 55001, dbPort: 55002 }, }, ); expect(managedStackDocumentPath(roots.stateRoot, stackId)).toBe( @@ -673,23 +681,125 @@ 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("rejects an explicit mode change before attaching to a running owner", async () => { + const roots = await workspace(); + const binDir = mkdtempSync(join(tmpdir(), "sup-stack-mode-attach-")); + const docker = join(binDir, "docker"); + writeFileSync(docker, "#!/bin/sh\nexit 0\n"); + chmodSync(docker, 0o755); + const nativeInput = messageFor(roots); + const dockerInput = messageFor(roots, { + config: { ...nativeInput.config, mode: "docker" }, + }); + const environment = { PATH: `${binDir}:${process.env["PATH"] ?? ""}` }; + const owner = spawnChild(dockerInput, { environment }); + let contender: ChildHandle | undefined; + let sameMode: ChildHandle | undefined; + try { + const started = await owner.started; + contender = spawnChild(nativeInput, { environment }); + await expect(contender.started).rejects.toThrow( + "Stack runtime is already docker; requested native", + ); + await waitForExit(contender.child); + + sameMode = spawnChild(dockerInput, { environment }); + const attached = await sameMode.started; + expect(attached.attached).toBe(true); + await remoteStop(started.endpoint); + await Promise.all([waitForExit(owner.child), waitForExit(sameMode.child)]); + } finally { + if (owner.child.exitCode === null) await kill(owner.child); + if (contender?.child.exitCode === null) await kill(contender.child); + if (sameMode?.child.exitCode === null) await kill(sameMode.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" }); + const stopBegan = join(roots.root, "stop-began"); + const child = spawnChild(messageFor(roots), { + testMode: "hold-stop", + environment: { SUPABASE_STACK_TEST_STOP_BEGAN_FILE: stopBegan }, + }); try { const started = await child.started; expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "running", ready: true }); void fetch(`${started.endpoint.url}/stop`, { method: "POST" }).catch(() => undefined); - const deadline = Date.now() + 2_000; - let stopping = false; - while (Date.now() < deadline) { - if ((await fetchOwner(started.endpoint).catch(() => undefined))?.state === "stopping") { - stopping = true; - break; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - expect(stopping).toBe(true); + await waitForFile(stopBegan); + expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "stopping" }); } finally { if (child.child.exitCode === null) await kill(child.child); cleanupRoots(roots); @@ -698,7 +808,12 @@ describe("detached supervisor child journeys", () => { test("Bun routes a ready-owner stop through the daemon shutdown transaction", async () => { const roots = await workspace(); - const child = spawnChild(messageFor(roots), { testMode: "hold-stop", platform: "bun" }); + const stopBegan = join(roots.root, "stop-began"); + const child = spawnChild(messageFor(roots), { + testMode: "hold-stop", + platform: "bun", + environment: { SUPABASE_STACK_TEST_STOP_BEGAN_FILE: stopBegan }, + }); try { const started = await child.started; let responseSettled = false; @@ -711,13 +826,7 @@ describe("detached supervisor child journeys", () => { responseSettled = true; return undefined; }); - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { - if ((await fetchOwner(started.endpoint).catch(() => undefined))?.state === "stopping") { - break; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } + await waitForFile(stopBegan); expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "stopping" }); expect(responseSettled).toBe(false); await kill(child.child); @@ -731,22 +840,20 @@ describe("detached supervisor child journeys", () => { test("starts after an owner finishes stopping", async () => { const roots = await workspace(); const releaseFile = join(roots.root, "release-stop"); + const stopBegan = join(roots.root, "stop-began"); const input = messageFor(roots); const owner = spawnChild(input, { testMode: "hold-stop", - environment: { SUPABASE_STACK_TEST_STOP_RELEASE_FILE: releaseFile }, + environment: { + SUPABASE_STACK_TEST_STOP_RELEASE_FILE: releaseFile, + SUPABASE_STACK_TEST_STOP_BEGAN_FILE: stopBegan, + }, }); let contender: ChildHandle | undefined; try { const started = await owner.started; const stop = fetch(`${started.endpoint.url}/stop`, { method: "POST" }).catch(() => undefined); - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { - if ((await fetchOwner(started.endpoint).catch(() => undefined))?.state === "stopping") { - break; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } + await waitForFile(stopBegan); expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "stopping" }); contender = spawnChild(input); @@ -920,7 +1027,10 @@ describe("detached supervisor child journeys", () => { await Promise.race([ waitForExit(child.child), new Promise((_, reject) => - setTimeout(() => reject(new Error("owner did not stop")), 2_000), + setTimeout( + () => reject(new Error(`owner did not stop within ${FILE_WAIT_TIMEOUT_MS}ms`)), + FILE_WAIT_TIMEOUT_MS, + ), ), ]); const stopped = await waitForStackDocument(roots, "stopped"); @@ -1015,16 +1125,8 @@ describe("detached supervisor child journeys", () => { await kill(owner.child); await expect(owner.started).rejects.toThrow(); - const deadline = Date.now() + 3_000; - let restarted = false; - while (Date.now() < deadline) { - if ((await fetchOwner(restartedEndpoint).catch(() => undefined))?.state === "starting") { - restarted = true; - break; - } - await new Promise((resolve) => setTimeout(resolve, 20)); - } - expect(restarted).toBe(true); + await contender.managedStarted; + expect(await fetchOwner(restartedEndpoint)).toMatchObject({ state: "starting" }); } finally { if (owner.child.exitCode === null) await kill(owner.child); if (contender?.child.exitCode === null) await kill(contender.child); @@ -1150,9 +1252,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..a42cb0c5ec 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,16 +32,25 @@ 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 } 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"; -import { resolveConfig, type DaemonConfigInput } from "./StackConfigResolver.ts"; +import type { PortLease } from "./PortAllocator.ts"; +import { + portRequestsForConfig, + resolveConfig, + type DaemonConfigInput, +} from "./StackConfigResolver.ts"; import type { ResolvedDaemonConfig } from "./StackConfig.ts"; import { HttpTransportClient } from "./HttpTransportClient.ts"; import { RemoteStack } from "./RemoteStack.ts"; @@ -51,7 +66,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 +88,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 +105,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 +121,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; @@ -247,18 +272,6 @@ const waitForSignal = (): Effect.Effect<"SIGINT" | "SIGTERM"> => return Effect.sync(cleanup); }); -const leaseFacade = (lease: { - readonly ports: PortSet; - readonly reserve: (fields: ReadonlyArray) => Effect.Effect; - readonly release: (fields: ReadonlyArray) => Effect.Effect; - readonly releaseAll: Effect.Effect; -}): PortLease => ({ - ports: lease.ports, - reserve: lease.reserve, - release: lease.release, - releaseAll: lease.releaseAll, -}); - const startDaemon = (input: { readonly config: ResolvedDaemonConfig; readonly lease: PortLease; @@ -266,7 +279,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 +323,7 @@ const runManaged = ( | ControlTransport | import("effect").FileSystem.FileSystem | import("effect").Path.Path + | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > => { let owner: ControlOwnership | undefined; @@ -348,6 +362,31 @@ const runManaged = ( new SupervisorStartError({ message: "Workspace identity changed before supervisor start" }), ); } + const requestedMode = + configInput.mode ?? + (input.launch !== undefined && "mode" in input.launch ? input.launch.mode : undefined); + const existing = yield* manager.inspectStack(stackId); + 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 ( + initialAcquisition._tag === "Attached" && + 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.`, + }), + ); + } let attachedOwnerWasStopping = false; const reacquireAfterDeath = (): Effect.Effect => manager.acquireControl(stackId).pipe( @@ -444,8 +483,39 @@ const runManaged = ( ); } } + 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), + ), + }; + // Validate policies and explicit ports before manager.startStack writes + // `starting` or acquires the managed lease. + yield* portRequestsForConfig(configInput, { runtime }); + 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,33 +523,32 @@ 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({ - try: () => - resolveConfig( - { - ...configInput, - projectDir: configInput.projectDir ?? input.workspacePath, - stackRoot: managedStackPaths(input.stateRoot, started.stack.id).root, - runtimeRoot: managedStackPaths(input.stateRoot, started.stack.id).runtime, - instanceId: started.stack.id, - }, - { portAllocator: () => Effect.succeed(started.lease.ports) }, - ), - catch: (cause) => cause, - }); + const resolved = yield* resolveConfig( + { + ...configInput, + projectDir: configInput.projectDir ?? input.workspacePath, + stackRoot: managedStackPaths(input.stateRoot, started.stack.id).root, + runtimeRoot: managedStackPaths(input.stateRoot, started.stack.id).runtime, + instanceId: started.stack.id, + }, + { runtime, ports: started.lease.ports }, + ); const config: ResolvedDaemonConfig = { ...resolved, name: input.stackName, @@ -491,7 +560,7 @@ const runManaged = ( }); const built = yield* startDaemon({ config, - lease: leaseFacade(started.lease), + lease: started.lease, ownership, platform, scope, @@ -568,7 +637,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 +648,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, @@ -689,9 +761,7 @@ export const supervisorLayer = ( ); }).pipe( Effect.onExit(() => - detached - ? Effect.void - : Effect.promise(() => terminateChildProcess(child)).pipe(Effect.ignore), + detached ? Effect.void : terminateChildProcess(child).pipe(Effect.ignore), ), ); }); diff --git a/packages/stack/src/terminateChild.ts b/packages/stack/src/terminateChild.ts index abe74bb1f0..3f51cca36c 100644 --- a/packages/stack/src/terminateChild.ts +++ b/packages/stack/src/terminateChild.ts @@ -1,3 +1,5 @@ +import { Duration, Effect } from "effect"; + interface ChildLike { readonly pid?: number; readonly exitCode?: number | null; @@ -10,60 +12,59 @@ interface ChildLike { const hasAlreadyExited = (child: ChildLike): boolean => child.exitCode != null || child.signalCode != null; -export const terminateChildProcess = async ( +const terminateWithSignal = ( child: ChildLike, - opts: { - readonly timeoutMs?: number; - } = {}, -): Promise => { - if (child.pid == null) { - return; - } - // An already-exited child never fires another `exit` event, so the waits - // below would burn their full SIGTERM + SIGKILL timeouts listening for one. - if (hasAlreadyExited(child)) { - return; - } - - const timeoutMs = opts.timeoutMs ?? 1_000; - - const termExit = waitForChildExit(child, timeoutMs); - try { - child.kill("SIGTERM"); - } catch {} - - if (await termExit) { - return; - } - if (hasAlreadyExited(child)) { - return; - } + signal: NodeJS.Signals, + timeoutMs: number, +): Effect.Effect => + Effect.raceFirst( + Effect.callback((resume) => { + let cleaned = false; + const onExit = () => { + cleanup(); + resume(Effect.succeed(true)); + }; + const cleanup = () => { + if (cleaned) return; + cleaned = true; + child.off("exit", onExit); + }; - const killExit = waitForChildExit(child, timeoutMs); - try { - child.kill("SIGKILL"); - } catch {} + child.once("exit", onExit); + if (hasAlreadyExited(child)) { + onExit(); + } else { + try { + child.kill(signal); + } catch { + // A child may disappear between the exit check and kill. The timeout + // stage still bounds this wait, and the next stage rechecks state. + } + } - await killExit; -}; + return Effect.sync(cleanup); + }), + Effect.sleep(Duration.millis(timeoutMs)).pipe(Effect.as(false)), + ); -function waitForChildExit(child: ChildLike, timeoutMs: number): Promise { - return new Promise((resolve) => { - const onExit = () => { - cleanup(); - resolve(true); - }; - - const timeout = setTimeout(() => { - cleanup(); - resolve(false); - }, timeoutMs); +export const terminateChildProcess = ( + child: ChildLike, + opts: { + readonly timeoutMs?: number; + } = {}, +): Effect.Effect => + Effect.gen(function* () { + if (child.pid == null || hasAlreadyExited(child)) { + return; + } - const cleanup = () => { - clearTimeout(timeout); - child.off("exit", onExit); - }; + const timeoutMs = opts.timeoutMs ?? 1_000; + if (yield* terminateWithSignal(child, "SIGTERM", timeoutMs)) { + return; + } + if (hasAlreadyExited(child)) { + return; + } - child.once("exit", onExit); + yield* terminateWithSignal(child, "SIGKILL", timeoutMs); }); -} diff --git a/packages/stack/src/terminateChild.unit.test.ts b/packages/stack/src/terminateChild.unit.test.ts index de37d37f12..7887fd2aa8 100644 --- a/packages/stack/src/terminateChild.unit.test.ts +++ b/packages/stack/src/terminateChild.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { Effect, Fiber } from "effect"; import { terminateChildProcess } from "./terminateChild.ts"; interface ChildLike { @@ -14,6 +15,10 @@ class FakeChild implements ChildLike { readonly signals: Array = []; #listeners = new Set<() => void>(); + get listenerCount(): number { + return this.#listeners.size; + } + constructor( private readonly onKill: (signal: NodeJS.Signals, child: FakeChild) => void = () => {}, ) {} @@ -47,7 +52,7 @@ describe("terminateChildProcess", () => { } }); - await terminateChildProcess(child, { timeoutMs: 100 }); + await Effect.runPromise(terminateChildProcess(child, { timeoutMs: 100 })); expect(child.signals).toEqual(["SIGTERM"]); }); @@ -59,7 +64,7 @@ describe("terminateChildProcess", () => { } }); - await terminateChildProcess(child, { timeoutMs: 10 }); + await Effect.runPromise(terminateChildProcess(child, { timeoutMs: 10 })); expect(child.signals).toEqual(["SIGTERM", "SIGKILL"]); }); @@ -73,7 +78,7 @@ describe("terminateChildProcess on an already-exited child", () => { // the sweep exists to prevent. const child = new FakeChild(); child.exitCode = 0; - await terminateChildProcess(child, { timeoutMs: 5_000 }); + await Effect.runPromise(terminateChildProcess(child, { timeoutMs: 5_000 })); expect(child.signals).toEqual([]); }); @@ -85,7 +90,7 @@ describe("terminateChildProcess on an already-exited child", () => { }); vi.useFakeTimers(); try { - const termination = terminateChildProcess(child, { timeoutMs: 300 }); + const termination = Effect.runPromise(terminateChildProcess(child, { timeoutMs: 300 })); await vi.runAllTimersAsync(); await termination; expect(child.signals).toEqual(["SIGTERM"]); @@ -93,4 +98,14 @@ describe("terminateChildProcess on an already-exited child", () => { vi.useRealTimers(); } }); + + it("removes the exit listener when termination is interrupted", async () => { + const child = new FakeChild(); + const fiber = Effect.runFork(terminateChildProcess(child, { timeoutMs: 1_000 })); + await Effect.runPromise(Effect.yieldNow); + + expect(child.listenerCount).toBe(1); + await Effect.runPromise(Fiber.interrupt(fiber)); + expect(child.listenerCount).toBe(0); + }); }); diff --git a/packages/stack/src/version-plan.unit.test.ts b/packages/stack/src/version-plan.unit.test.ts index afc6e400c1..474a52170b 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,14 +49,14 @@ describe("planStackVersions", () => { runtimeVersions: { ...DEFAULT_VERSIONS, postgres: "17.4.1.045", - postgrest: "14.5", - auth: "2.170.0", - storage: "1.40.0", + postgrest: "v14.5", + auth: "v2.170.0", + storage: "v1.40.0", }, activeOverrides: [ { service: "postgres", version: "17.4.1.045", source: "flag" }, - { service: "auth", version: "2.170.0", source: "flag" }, - { service: "storage", version: "1.40.0", source: "local" }, + { service: "auth", version: "v2.170.0", source: "flag" }, + { service: "storage", version: "v1.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..e0c3334c0b 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,28 +30,11 @@ 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); + return dockerImageForArtifact(service, normalizeServiceVersion(service, version)); } function assertFullVersions( @@ -70,28 +53,18 @@ 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 metadata = serviceMetadata(service); + const tagPrefix = metadata.artifact.docker.tagPrefix; + const withoutDockerTagPrefix = + tagPrefix !== undefined && normalized.startsWith(tagPrefix) + ? normalized.slice(tagPrefix.length) + : normalized; + return metadata.defaultVersion.startsWith("v") && !withoutDockerTagPrefix.startsWith("v") + ? `v${withoutDockerTagPrefix}` + : withoutDockerTagPrefix; } export function normalizeServiceVersions( @@ -136,4 +109,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..9aaa464767 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,66 +81,58 @@ 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", () => { + it("normalizes bare versions for services with v-prefixed catalog releases", () => { expect(normalizeServiceVersion("mailpit", "1.30.2")).toBe("v1.30.2"); expect(normalizeServiceVersion("imgproxy", "3.8.0")).toBe("v3.8.0"); }); @@ -149,6 +140,13 @@ describe("normalizeServiceVersion", () => { 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..f2882266a0 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" })]), ); @@ -201,4 +215,41 @@ dockerDescribe("createStack e2e (docker mode)", () => { expect(remaining.data).toHaveLength(0); }, ); + + test( + "restarts the Studio graph with its Pgmeta dependency", + { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, + async () => { + const graphDataDir = mkdtempSync(join(tmpdir(), "supabase-e2e-docker-graph-")); + let graphStack: StackHandle | undefined; + try { + graphStack = await createStack({ + mode: "docker", + postgres: { dataDir: graphDataDir }, + pgmeta: {}, + studio: {}, + }); + await graphStack.start(); + expect(await graphStack.getServiceStatus("pgmeta")).toEqual( + expect.objectContaining({ status: "Healthy" }), + ); + expect(await graphStack.getServiceStatus("studio")).toEqual( + expect.objectContaining({ status: "Healthy" }), + ); + + await graphStack.stop(); + await graphStack.start(); + + expect(await graphStack.getServiceStatus("pgmeta")).toEqual( + expect.objectContaining({ status: "Healthy" }), + ); + expect(await graphStack.getServiceStatus("studio")).toEqual( + expect.objectContaining({ status: "Healthy" }), + ); + } finally { + await graphStack?.dispose(); + rmSync(graphDataDir, { recursive: true, force: true }); + } + }, + ); }); 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..2ace232b50 --- /dev/null +++ b/packages/stack/tests/createStack-native.e2e.test.ts @@ -0,0 +1,88 @@ +import { createClient } from "@supabase/supabase-js"; +import { mkdtempSync, rmSync, symlinkSync } 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 { defaultCacheRoot } from "../src/paths.ts"; +import { setupTestTable } from "./helpers/e2e.ts"; + +describe("native PostgREST tracer bullet", () => { + let stack: StackHandle; + let dataDir: string; + let cacheParent: string; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), "supabase-native-postgrest-e2e-")); + cacheParent = mkdtempSync(join(tmpdir(), "supabase-native-cache-parent-")); + const cacheRoot = join(cacheParent, "cache root with spaces"); + symlinkSync(defaultCacheRoot(), cacheRoot, "dir"); + stack = await createStack({ + mode: "native", + cacheRoot, + 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 }); + rmSync(cacheParent, { 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); + + test("repairs incomplete bundled initialization on restart", async () => { + const adminUrl = new URL(stack.dbUrl); + adminUrl.username = "supabase_admin"; + const sql = new Bun.SQL(adminUrl.toString()); + try { + await sql.unsafe(` + DELETE FROM supabase_migrations.cli_init WHERE phase = 'complete'; + ALTER ROLE authenticator RESET session_preload_libraries; + `); + } finally { + await sql.close(); + } + + await stack.stop(); + await stack.start(); + + const check = new Bun.SQL(adminUrl.toString()); + try { + const rows = await check.unsafe<{ configured: boolean }[]>(` + SELECT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'authenticator' + AND EXISTS ( + SELECT 1 + FROM unnest(coalesce(rolconfig, ARRAY[]::text[])) setting + WHERE setting LIKE 'session_preload_libraries=supautils%' + ) + ) AS configured; + `); + expect(rows[0]?.configured).toBe(true); + } finally { + await check.close(); + } + }, 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/managed-manager.ts b/packages/stack/tests/helpers/managed-manager.ts index aa1b89b8c6..fa26cb7750 100644 --- a/packages/stack/tests/helpers/managed-manager.ts +++ b/packages/stack/tests/helpers/managed-manager.ts @@ -1,3 +1,4 @@ +import { NodeFileSystem } from "@effect/platform-node"; import { Effect, Stream } from "effect"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { createServer, type Server } from "node:http"; @@ -105,7 +106,7 @@ export const freePorts = ( field, selection: { kind: "automatic" as const }, })), - ); + ).pipe(Effect.provide(NodeFileSystem.layer)); const ports = FREE_PORT_FIELDS.slice(0, count).flatMap((field) => { const port = lease.ports[field]; return port === undefined ? [] : [port]; 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/port-lease-child.ts b/packages/stack/tests/helpers/port-lease-child.ts new file mode 100644 index 0000000000..b5b220a1c0 --- /dev/null +++ b/packages/stack/tests/helpers/port-lease-child.ts @@ -0,0 +1,18 @@ +import { NodeFileSystem } from "@effect/platform-node"; +import { Effect } from "effect"; +import { reservePortSet } from "../../src/PortAllocator.ts"; + +const lease = await Effect.runPromise( + reservePortSet([ + { field: "apiPort", selection: { kind: "automatic" } }, + { field: "dbPort", selection: { kind: "automatic" } }, + ]).pipe(Effect.provide(NodeFileSystem.layer)), +); + +process.stdout.write(`${JSON.stringify(lease.ports)}\n`); + +for await (const _chunk of process.stdin) { + break; +} + +await Effect.runPromise(lease.releaseAll); diff --git a/packages/stack/tests/helpers/spawn-stack.ts b/packages/stack/tests/helpers/spawn-stack.ts deleted file mode 100644 index 518d190cba..0000000000 --- a/packages/stack/tests/helpers/spawn-stack.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { type ChildProcess, spawn } from "node:child_process"; -import { resolve } from "node:path"; -import { terminateChildProcess } from "../../src/terminateChild.ts"; - -const STANDALONE_SCRIPT = resolve(import.meta.dirname, "standalone-stack.ts"); -const DEFAULT_READINESS_TIMEOUT_MS = 60_000; -const OUTPUT_TAIL_CHARS = 2_000; - -export interface SpawnedStackInfo { - readonly url: string; - readonly dbUrl: string; - readonly process: ChildProcess; -} - -export interface SpawnStandaloneStackOptions { - /** Overridable for unit tests only; the e2e suite always runs the real script. */ - readonly command?: readonly [string, ...string[]]; - readonly readinessTimeoutMs?: number; - /** - * Fired the moment the child exists, before readiness. Callers register the - * handle here so teardown can terminate every spawned child even when the - * readiness promise never resolved — a `Promise.all` that dies on one stack - * must not orphan its siblings. - */ - readonly onSpawn?: (child: ChildProcess) => void; -} - -/** - * Spawns one standalone stack subprocess and resolves when it reports - * readiness (a single JSON line on stdout). Unlike a bare spawn-and-parse, - * every way the child can fail settles the promise with the evidence attached: - * - * - exit before readiness — ANY code, including 0 — rejects with the code and - * the child's stderr, so a stack that dies cleanly during bring-up cannot - * turn into an opaque hook timeout with its error discarded; - * - readiness not reported within `readinessTimeoutMs` rejects with the - * stdout/stderr collected so far and terminates the child, so a bring-up - * that wedges (e.g. a port race) fails fast and names the last thing the - * stack said instead of burning the whole hook budget. - */ -export function spawnStandaloneStack( - opts: SpawnStandaloneStackOptions = {}, -): Promise { - const [command, ...args] = opts.command ?? [ - "bun", - "run", - STANDALONE_SCRIPT, - "--parent-pid", - String(process.pid), - ]; - const readinessTimeoutMs = opts.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS; - - return new Promise((resolvePromise, rejectPromise) => { - const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); - opts.onSpawn?.(child); - - let stdout = ""; - let stderr = ""; - let settled = false; - - const settle = (outcome: { info?: SpawnedStackInfo; error?: Error }) => { - if (settled) return; - settled = true; - clearTimeout(readinessTimer); - if (outcome.info !== undefined) resolvePromise(outcome.info); - else rejectPromise(outcome.error); - }; - - const outputTail = () => - `stdout: ${stdout.slice(-OUTPUT_TAIL_CHARS) || "(none)"}\nstderr: ${ - stderr.slice(-OUTPUT_TAIL_CHARS) || "(none)" - }`; - - const readinessTimer = setTimeout(() => { - settle({ - error: new Error( - `Stack did not report readiness within ${readinessTimeoutMs}ms\n${outputTail()}`, - ), - }); - // Reclaim the unusable child; the 30s window matches the suite's own - // sweep so SIGKILL doesn't cut a wedged stack's dispose short. - void terminateChildProcess(child, { timeoutMs: 30_000 }); - }, readinessTimeoutMs); - - child.stdout!.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - const newline = stdout.indexOf("\n"); - if (newline !== -1) { - try { - const info = JSON.parse(stdout.slice(0, newline)); - settle({ info: { url: info.url, dbUrl: info.dbUrl, process: child } }); - } catch { - settle({ error: new Error(`Failed to parse stack info: ${stdout.slice(0, newline)}`) }); - } - } - }); - - child.stderr!.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - - child.on("error", (err) => settle({ error: err })); - // Any exit before readiness is a failure — including a clean 0. `close` - // rather than `exit`: it waits for the stdio pipes to drain, so the tails - // below always carry whatever the child managed to say. - child.on("close", (code) => { - settle({ - error: new Error( - `Stack process exited with code ${code} before readiness\n${outputTail()}`, - ), - }); - }); - }); -} diff --git a/packages/stack/tests/helpers/spawn-stack.unit.test.ts b/packages/stack/tests/helpers/spawn-stack.unit.test.ts deleted file mode 100644 index bff1bbc7cb..0000000000 --- a/packages/stack/tests/helpers/spawn-stack.unit.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { type ChildProcess } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, describe, expect, test } from "vitest"; -import { terminateChildProcess } from "../../src/terminateChild.ts"; -import { spawnStandaloneStack } from "./spawn-stack.ts"; - -const dir = mkdtempSync(join(tmpdir(), "spawn-stack-unit-")); -const children: ChildProcess[] = []; - -afterAll(() => { - for (const child of children) { - try { - child.kill("SIGKILL"); - } catch {} - } - rmSync(dir, { recursive: true, force: true }); -}); - -function stub(name: string, source: string): readonly [string, ...string[]] { - const path = join(dir, name); - writeFileSync(path, source); - return ["bun", "run", path]; -} - -const track = (child: ChildProcess) => children.push(child); - -describe("spawnStandaloneStack", () => { - test("resolves with the reported url/dbUrl and a live process handle", async () => { - const command = stub( - "ok.ts", - `console.log(JSON.stringify({ url: "http://127.0.0.1:59991", dbUrl: "postgresql://127.0.0.1:59992/x" })); - setInterval(() => {}, 60_000);`, - ); - const info = await spawnStandaloneStack({ command, onSpawn: track }); - expect(info.url).toBe("http://127.0.0.1:59991"); - expect(info.dbUrl).toBe("postgresql://127.0.0.1:59992/x"); - expect(info.process.exitCode).toBeNull(); - }); - - test("rejects with the exit code and stderr when the child dies cleanly before readiness", async () => { - // The pre-fix harness only rejected on a NON-zero exit, so this exact - // child left the promise pending until the 90s hook timeout, with the - // stderr below discarded — the opaque paired-timeout CI failure. - const command = stub( - "silent-exit0.ts", - `process.stderr.write("boot: port 54322 already bound, giving up\\n"); - process.exit(0);`, - ); - await expect(spawnStandaloneStack({ command, onSpawn: track })).rejects.toThrow( - /exited with code 0 before readiness[\s\S]*port 54322 already bound/, - ); - }); - - test("rejects on readiness timeout and reclaims the child", async () => { - const command = stub( - "hang.ts", - `process.stderr.write("boot: waiting for postgres socket...\\n"); - setInterval(() => {}, 60_000);`, - ); - const spawned: ChildProcess[] = []; - let exited: - | Promise<{ - readonly code: number | null; - readonly signal: NodeJS.Signals | null; - }> - | undefined; - await expect( - spawnStandaloneStack({ - command, - readinessTimeoutMs: 1_500, - onSpawn: (child) => { - track(child); - spawned.push(child); - exited = new Promise((resolve) => - child.once("exit", (code, signal) => resolve({ code, signal })), - ); - }, - }), - ).rejects.toThrow(/did not report readiness within 1500ms/); - // The helper terminates its own unusable child rather than leaving an - // interval-driven zombie for suite teardown to hunt. - const exit = await exited; - expect(exit).toEqual({ code: null, signal: "SIGTERM" }); - expect(spawned[0]?.killed).toBe(true); - }); - - test("rejects on an unparseable readiness line", async () => { - const command = stub("garbage.ts", `console.log("not json"); setInterval(() => {}, 60_000);`); - await expect(spawnStandaloneStack({ command, onSpawn: track })).rejects.toThrow( - /Failed to parse stack info: not json/, - ); - }); - - test("registers every child via onSpawn before readiness, so a failed sibling cannot orphan a healthy one", async () => { - const okCommand = stub( - "ok-sibling.ts", - `console.log(JSON.stringify({ url: "http://127.0.0.1:59993", dbUrl: "postgresql://127.0.0.1:59994/x" })); - setInterval(() => {}, 60_000);`, - ); - const badCommand = stub("bad-sibling.ts", `process.exit(0);`); - - const registered: ChildProcess[] = []; - const results = await Promise.allSettled([ - spawnStandaloneStack({ onSpawn: (c) => registered.push(c), command: okCommand }), - spawnStandaloneStack({ onSpawn: (c) => registered.push(c), command: badCommand }), - ]); - children.push(...registered); - - expect(registered).toHaveLength(2); - expect(results.map((r) => r.status).sort()).toEqual(["fulfilled", "rejected"]); - // The healthy sibling's handle is reachable through the registry even - // though Promise.all-style consumption would have discarded its value. - const healthy = registered.find((c) => c.exitCode === null); - expect(healthy).toBeDefined(); - }); - - test("teardown sweep over a dead child is a no-op", async () => { - // The incident replay: one sibling died before readiness, teardown then - // sweeps every registered child with a 30s timeout. Before the - // already-exited guard in terminateChildProcess this call burned 60s - // doing nothing — reproducing the afterAll hook timeout it was meant to - // prevent. - const command = stub("dead-sweep.ts", `process.exit(0);`); - const registered: ChildProcess[] = []; - await spawnStandaloneStack({ command, onSpawn: (c) => registered.push(c) }).catch(() => {}); - expect(registered[0]?.exitCode).toBe(0); - await terminateChildProcess(registered[0]!, { timeoutMs: 30_000 }); - expect(registered[0]?.exitCode).toBe(0); - }); -}); diff --git a/packages/stack/tests/helpers/stack-ports.ts b/packages/stack/tests/helpers/stack-ports.ts index d76223e9e7..06fe40be4f 100644 --- a/packages/stack/tests/helpers/stack-ports.ts +++ b/packages/stack/tests/helpers/stack-ports.ts @@ -1,3 +1,4 @@ +import { NodeFileSystem } from "@effect/platform-node"; import { Effect } from "effect"; import { createStack, type StackHandle } from "../../src/node.ts"; import { reservePortSet } from "../../src/PortAllocator.ts"; @@ -37,7 +38,7 @@ const reserveEphemeralStackPorts = async (): Promise => return { apiPort, dbPort }; }), ), - ), + ).pipe(Effect.provide(NodeFileSystem.layer)), ); /** diff --git a/packages/stack/tests/helpers/standalone-stack.ts b/packages/stack/tests/helpers/standalone-stack.ts deleted file mode 100644 index ccc6dfb646..0000000000 --- a/packages/stack/tests/helpers/standalone-stack.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { createStack } from "../../src/node.ts"; - -// Registered before any bring-up work: the spawning harness SIGTERMs a stack -// that misses its readiness deadline, and without these the default signal -// disposition kills the process mid-start with temp dirs and containers left -// behind for the leak check to trip over. A pre-readiness signal is remembered -// and honored at the next await boundary via a dispose-then-exit. -let earlyShutdownRequested = false; -let signalEarlyShutdown = () => { - earlyShutdownRequested = true; -}; -const earlyShutdown = new Promise<"early-shutdown">((resolveSignal) => { - signalEarlyShutdown = () => { - earlyShutdownRequested = true; - resolveSignal("early-shutdown"); - }; -}); -const onEarlySignal = () => signalEarlyShutdown(); -process.once("SIGINT", onEarlySignal); -process.once("SIGTERM", onEarlySignal); - -const parentPid = readParentPid(process.argv.slice(2)); -const stack = await createStack(); -if (earlyShutdownRequested) { - await stack.dispose(); - process.exit(0); -} -// Raced rather than awaited directly: a signal during a HUNG start() must -// still dispose whatever was already created — a flag alone can't run until -// the await returns, which is exactly when it never will. -const starting = stack.start().then( - () => "started" as const, - (error) => { - if (!earlyShutdownRequested) throw error; - return "start-failed" as const; - }, -); -if ((await Promise.race([starting, earlyShutdown])) !== "started") { - await stack.dispose(); - process.exit(0); -} -if (earlyShutdownRequested) { - await stack.dispose(); - process.exit(0); -} -process.off("SIGINT", onEarlySignal); -process.off("SIGTERM", onEarlySignal); - -// Signal readiness to parent process -console.log(JSON.stringify({ url: stack.url, dbUrl: stack.dbUrl })); - -await waitForShutdown(parentPid); -await stack.dispose(); -process.exit(0); - -function waitForShutdown(parentPid: number | undefined): Promise { - return new Promise((resolve) => { - const onShutdown = () => { - cleanup(); - resolve(); - }; - - const onParentExit = () => { - onShutdown(); - }; - - const parentWatchdog = - parentPid == null - ? undefined - : setInterval(() => { - if (!isProcessAlive(parentPid)) { - onParentExit(); - } - }, 250); - - parentWatchdog?.unref(); - - const cleanup = () => { - process.off("SIGINT", onShutdown); - process.off("SIGTERM", onShutdown); - process.off("disconnect", onParentExit); - if (parentWatchdog != null) { - clearInterval(parentWatchdog); - } - }; - - process.once("SIGINT", onShutdown); - process.once("SIGTERM", onShutdown); - process.once("disconnect", onParentExit); - }); -} - -function readParentPid(argv: ReadonlyArray): number | undefined { - const flagIndex = argv.indexOf("--parent-pid"); - const rawValue = flagIndex === -1 ? undefined : argv[flagIndex + 1]; - if (rawValue == null) { - return undefined; - } - - const value = Number.parseInt(rawValue, 10); - return Number.isInteger(value) && value > 0 ? value : undefined; -} - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} diff --git a/packages/stack/tests/helpers/supervisor-child.ts b/packages/stack/tests/helpers/supervisor-child.ts index 3883a4d19a..9b34981341 100644 --- a/packages/stack/tests/helpers/supervisor-child.ts +++ b/packages/stack/tests/helpers/supervisor-child.ts @@ -27,11 +27,35 @@ import type { ResolvedDaemonConfig } from "../../src/StackConfig.ts"; type TestMode = "bind-all" | "fail-after-bind" | "hold-reservations" | "hold-start" | "hold-stop"; const waitForFile = (path: string): Effect.Effect => - Effect.suspend(() => - existsSync(path) - ? Effect.void - : Effect.sleep("10 millis").pipe(Effect.andThen(waitForFile(path))), - ); + Effect.callback((resume) => { + if (existsSync(path)) { + resume(Effect.void); + return Effect.void; + } + let settled = false; + let watcher: FSWatcher | undefined; + const cleanup = () => { + watcher?.close(); + watcher = undefined; + }; + const settle = (result: Effect.Effect) => { + if (settled) return; + settled = true; + cleanup(); + resume(result); + }; + const check = () => { + if (existsSync(path)) settle(Effect.void); + }; + try { + watcher = watch(dirname(path), check); + watcher.once("error", (cause) => settle(Effect.die(cause))); + check(); + } catch (cause) { + settle(Effect.die(cause)); + } + return Effect.sync(cleanup); + }); const testMode = (): TestMode => { const value = process.env["SUPABASE_STACK_TEST_RUNTIME_MODE"]; @@ -87,7 +111,18 @@ const testStackLayer = (config: ResolvedDaemonConfig, mode: TestMode): Layer.Lay return Layer.succeed(Stack, { getInfo: () => Effect.succeed(info), start: () => Effect.void, - stop: () => (mode === "hold-stop" ? waitForStopRelease() : Effect.void), + stop: () => + mode === "hold-stop" + ? Effect.gen(function* () { + const stageFile = process.env["SUPABASE_STACK_TEST_STOP_BEGAN_FILE"]; + if (stageFile === undefined) { + yield* sendTestStage("stop-began").pipe(Effect.orDie); + } else { + yield* Effect.sync(() => writeFileSync(stageFile, "began")); + } + yield* waitForStopRelease(); + }) + : Effect.void, dispose: () => Effect.void, startService: () => Effect.void, stopService: () => Effect.void, @@ -182,14 +217,16 @@ const waitForAttachedBeforeReadyRelease = (): Effect.Effect => { }); }; -const sendTestStage = (): Effect.Effect => +const sendTestStage = ( + stage: "attached-before-ready" | "managed-started" | "stop-began", +): Effect.Effect => Effect.callback((resume) => { if (process.send === undefined || !process.connected) { resume(Effect.void); return Effect.void; } try { - process.send({ type: "test-stage", stage: "attached-before-ready" }, (error) => + process.send({ type: "test-stage", stage }, (error) => resume( error === null ? Effect.void @@ -206,7 +243,10 @@ const sendTestStage = (): Effect.Effect => ); } return Effect.void; - }).pipe(Effect.andThen(waitForAttachedBeforeReadyRelease())); + }); + +const sendAttachedBeforeReadyStage = (): Effect.Effect => + sendTestStage("attached-before-ready").pipe(Effect.andThen(waitForAttachedBeforeReadyRelease())); const resolutionTimeout = (): Duration.Input => { const milliseconds = Number(process.env["SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS"]); @@ -233,17 +273,24 @@ const managerLayer = (stateRoot: string, platform: "node" | "bun") => (base) => { const readyFile = process.env["SUPABASE_STACK_TEST_ENSURE_READY_FILE"]; const releaseFile = process.env["SUPABASE_STACK_TEST_ENSURE_RELEASE_FILE"]; - if (readyFile === undefined || releaseFile === undefined) return base; return Layer.effect( ManagedStackManager, ManagedStackManager.pipe( Effect.map((manager) => ({ ...manager, - ensureWorkspace: (workspacePath: string) => - Effect.sync(() => writeFileSync(readyFile, "ready")).pipe( - Effect.andThen(waitForFile(releaseFile)), - Effect.andThen(manager.ensureWorkspace(workspacePath)), - ), + startStack: (input: Parameters[0]) => + manager + .startStack(input) + .pipe(Effect.tap(() => sendTestStage("managed-started").pipe(Effect.orDie))), + ...(readyFile === undefined || releaseFile === undefined + ? {} + : { + ensureWorkspace: (workspacePath: string) => + Effect.sync(() => writeFileSync(readyFile, "ready")).pipe( + Effect.andThen(waitForFile(releaseFile)), + Effect.andThen(manager.ensureWorkspace(workspacePath)), + ), + }), })), ), ).pipe(Layer.provide(base)); @@ -258,7 +305,7 @@ export const runTestSupervisor = (): void => { platformFactory: platformKind === "bun" ? bunPlatformFactory : nodePlatformFactory, managerLayer: (stateRoot) => managerLayer(stateRoot, platformKind), runtimeLayer: testRuntime, - onAttachedBeforeReady: sendTestStage, + onAttachedBeforeReady: sendAttachedBeforeReadyStage, resolutionTimeout: resolutionTimeout(), }; const program = runSupervisor(supervisorPlatform).pipe( 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/parallelStacks.e2e.test.ts b/packages/stack/tests/parallelStacks.e2e.test.ts deleted file mode 100644 index 14dd7358a9..0000000000 --- a/packages/stack/tests/parallelStacks.e2e.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type ChildProcess } from "node:child_process"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { terminateChildProcess } from "../src/terminateChild.ts"; -import { type SpawnedStackInfo, spawnStandaloneStack } from "./helpers/spawn-stack.ts"; - -const STACK_COUNT = 2; -const PARALLEL_STACK_TEST_TIMEOUT_MS = 30_000; - -describe("parallel stacks (multi-process)", () => { - const stacks: SpawnedStackInfo[] = []; - // Registered at spawn time, not readiness: when one stack fails bring-up, - // `Promise.all` discards its healthy siblings' values, so this list — not - // `stacks` — is what teardown owns. - const children: ChildProcess[] = []; - - beforeAll(async () => { - const results = await Promise.all( - Array.from({ length: STACK_COUNT }, () => - spawnStandaloneStack({ onSpawn: (child) => children.push(child) }), - ), - ); - stacks.push(...results); - }, 90_000); - - afterAll(async () => { - await Promise.allSettled( - children.map((child) => terminateChildProcess(child, { timeoutMs: 30_000 })), - ); - expect(children.every((child) => child.exitCode !== null || child.signalCode !== null)).toBe( - true, - ); - }, 60_000); - - test("all stacks use different API ports", { timeout: PARALLEL_STACK_TEST_TIMEOUT_MS }, () => { - const ports = stacks.map((s) => new URL(s.url).port); - expect(new Set(ports).size).toBe(STACK_COUNT); - }); - - test("all stacks use different DB ports", { timeout: PARALLEL_STACK_TEST_TIMEOUT_MS }, () => { - const ports = stacks.map((s) => new URL(s.dbUrl).port); - expect(new Set(ports).size).toBe(STACK_COUNT); - }); - - test( - "all stacks respond to health checks", - { timeout: PARALLEL_STACK_TEST_TIMEOUT_MS }, - async () => { - const responses = await Promise.all( - stacks.map((s) => fetch(`${s.url}/health`, { signal: AbortSignal.timeout(20_000) })), - ); - for (const res of responses) { - expect(res.status).toBe(200); - } - }, - ); -}); 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 () => {