diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts index b13a63f0fc..77285919f6 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts @@ -4,7 +4,7 @@ import { resolveProjectSubtree, type ProjectConfig, } from "@supabase/config"; -import type { EdgeRuntimeConfig } from "@supabase/stack/effect"; +import type { EdgeRuntimeReloadOptions } from "@supabase/stack/effect"; import { Data, Effect, Redacted } from "effect"; import { ProjectHome } from "../../../config/project-home.service.ts"; @@ -30,7 +30,7 @@ export class FunctionsDevEdgeRuntimeDisabledError extends Data.TaggedError( } export interface ResolvedFunctionsDevEdgeRuntimeConfig { - readonly config: EdgeRuntimeConfig; + readonly config: EdgeRuntimeReloadOptions; readonly fingerprint: string; } @@ -64,7 +64,7 @@ function stableRecord( ); } -function fingerprintEdgeRuntimeConfig(config: EdgeRuntimeConfig): string { +function fingerprintEdgeRuntimeConfig(config: EdgeRuntimeReloadOptions): string { return JSON.stringify({ enabled: config.enabled, inspectorPort: config.inspectorPort, @@ -73,7 +73,9 @@ function fingerprintEdgeRuntimeConfig(config: EdgeRuntimeConfig): string { }); } -function toStackEdgeRuntimeConfig(config: ResolvedProjectEdgeRuntimeConfig): EdgeRuntimeConfig { +function toStackEdgeRuntimeConfig( + config: ResolvedProjectEdgeRuntimeConfig, +): EdgeRuntimeReloadOptions { return { enabled: config.enabled, inspectorPort: config.inspector_port, diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 15e5fd07c8..040da03cd3 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -1,4 +1,5 @@ -import type { StackConfig, VersionManifest } from "@supabase/stack/effect"; +import { STACK_SERVICE_NAMES } from "@supabase/stack/effect"; +import type { StackConfig, StackServiceName, VersionManifest } from "@supabase/stack/effect"; export const excludedStackServices = [ "auth", @@ -22,23 +23,22 @@ export function toStartStackConfig( exclude: ReadonlyArray, mode: StartMode, ): StackConfig { - const excluded = new Set(exclude); + const excluded = new Set(exclude); + if (excluded.has("storage")) excluded.add("imgproxy"); + if (excluded.has("pgmeta")) excluded.add("studio"); + if (excluded.has("analytics")) excluded.add("vector"); + const services = STACK_SERVICE_NAMES.filter( + (service) => service !== "edge-runtime" || mode !== "native", + ).filter((service) => !excluded.has(service)); return { mode, - realtime: excluded.has("realtime") ? false : {}, - storage: excluded.has("storage") ? false : {}, - imgproxy: excluded.has("imgproxy") || excluded.has("storage") ? false : {}, - mailpit: excluded.has("mailpit") ? false : {}, - pgmeta: excluded.has("pgmeta") ? false : {}, - studio: excluded.has("studio") || excluded.has("pgmeta") ? false : {}, - analytics: excluded.has("analytics") ? false : {}, - vector: excluded.has("vector") || excluded.has("analytics") ? false : {}, - pooler: excluded.has("pooler") ? false : {}, - ...(excluded.has("auth") ? { auth: false } : {}), - ...(excluded.has("postgrest") ? { postgrest: false } : {}), + services, }; } +const hasService = (stackConfig: StackConfig, service: StackServiceName): boolean => + stackConfig.services?.includes(service) ?? false; + export function withServiceVersions( stackConfig: StackConfig, versions: Partial, @@ -50,47 +50,47 @@ export function withServiceVersions( ? stackConfig.postgres : { ...stackConfig.postgres, version: versions.postgres }, postgrest: - stackConfig.postgrest === false || versions.postgrest === undefined + !hasService(stackConfig, "postgrest") || versions.postgrest === undefined ? stackConfig.postgrest : { ...stackConfig.postgrest, version: versions.postgrest }, auth: - stackConfig.auth === false || versions.auth === undefined + !hasService(stackConfig, "auth") || versions.auth === undefined ? stackConfig.auth : { ...stackConfig.auth, version: versions.auth }, realtime: - stackConfig.realtime === false || versions.realtime === undefined + !hasService(stackConfig, "realtime") || versions.realtime === undefined ? stackConfig.realtime : { ...stackConfig.realtime, version: versions.realtime }, storage: - stackConfig.storage === false || versions.storage === undefined + !hasService(stackConfig, "storage") || versions.storage === undefined ? stackConfig.storage : { ...stackConfig.storage, version: versions.storage }, imgproxy: - stackConfig.imgproxy === false || versions.imgproxy === undefined + !hasService(stackConfig, "imgproxy") || versions.imgproxy === undefined ? stackConfig.imgproxy : { ...stackConfig.imgproxy, version: versions.imgproxy }, mailpit: - stackConfig.mailpit === false || versions.mailpit === undefined + !hasService(stackConfig, "mailpit") || versions.mailpit === undefined ? stackConfig.mailpit : { ...stackConfig.mailpit, version: versions.mailpit }, pgmeta: - stackConfig.pgmeta === false || versions.pgmeta === undefined + !hasService(stackConfig, "pgmeta") || versions.pgmeta === undefined ? stackConfig.pgmeta : { ...stackConfig.pgmeta, version: versions.pgmeta }, studio: - stackConfig.studio === false || versions.studio === undefined + !hasService(stackConfig, "studio") || versions.studio === undefined ? stackConfig.studio : { ...stackConfig.studio, version: versions.studio }, analytics: - stackConfig.analytics === false || versions.analytics === undefined + !hasService(stackConfig, "analytics") || versions.analytics === undefined ? stackConfig.analytics : { ...stackConfig.analytics, version: versions.analytics }, vector: - stackConfig.vector === false || versions.vector === undefined + !hasService(stackConfig, "vector") || versions.vector === undefined ? stackConfig.vector : { ...stackConfig.vector, version: versions.vector }, pooler: - stackConfig.pooler === false || versions.pooler === undefined + !hasService(stackConfig, "pooler") || versions.pooler === undefined ? stackConfig.pooler : { ...stackConfig.pooler, version: versions.pooler }, }; 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 776e8750f6..98e7738a80 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -9,15 +9,10 @@ describe("toStartStackConfig", () => { }); it("dedupes excluded services when building stack config", () => { - expect(toStartStackConfig(["auth", "auth"], "auto")).toMatchObject({ - mode: "auto", - auth: false, - }); - expect(toStartStackConfig(["auth", "postgrest"], "auto")).toMatchObject({ - mode: "auto", - auth: false, - postgrest: false, - }); + expect(toStartStackConfig(["auth", "auth"], "auto").services).not.toContain("auth"); + expect(toStartStackConfig(["auth", "postgrest"], "auto").services).toEqual( + expect.not.arrayContaining(["auth", "postgrest"]), + ); }); }); @@ -39,16 +34,15 @@ describe("withServiceVersions", () => { realtime: { version: "2.78.10" }, }); - expect( - withServiceVersions(toStartStackConfig(["auth", "storage"], "auto"), { - postgres: "17.6.1.090", - auth: "2.187.0", - storage: "1.39.2", - }), - ).toMatchObject({ + const excluded = withServiceVersions(toStartStackConfig(["auth", "storage"], "auto"), { + postgres: "17.6.1.090", + auth: "2.187.0", + storage: "1.39.2", + }); + expect(excluded).toMatchObject({ postgres: { version: "17.6.1.090" }, - auth: false, - storage: false, }); + expect(excluded.auth).toBeUndefined(); + expect(excluded.storage).toBeUndefined(); }); }); diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index d9e1aca763..052badf5a8 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -677,6 +677,7 @@ export function mockStack( startService: () => Effect.void, stopService: () => Effect.void, restartService: () => Effect.void, + ensureExtensionPreload: () => Effect.void, reloadFunctions: () => Effect.void, reloadEdgeRuntime: () => Effect.void, getState: () => diff --git a/apps/cli/tests/helpers/running-stack.ts b/apps/cli/tests/helpers/running-stack.ts index 261c512eee..c019cc2eea 100644 --- a/apps/cli/tests/helpers/running-stack.ts +++ b/apps/cli/tests/helpers/running-stack.ts @@ -152,6 +152,10 @@ function makeStackLayer(opts: { opts.states.some((state) => state.name === name) ? Effect.void : Effect.fail(new ServiceNotFoundError({ name })), + ensureExtensionPreload: (name: string) => + opts.states.some((state) => state.name === name) + ? Effect.void + : Effect.fail(new ServiceNotFoundError({ name })), reloadFunctions: () => opts.states.some((state) => state.name === "edge-runtime") ? Effect.void diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index a8c53dd4e5..333aca90db 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ test: { name: "unit", include: ["**/*.unit.test.ts"], + testTimeout: 30_000, }, }, { @@ -52,6 +53,7 @@ export default defineConfig({ test: { name: "integration", include: ["**/*.integration.test.ts"], + testTimeout: 30_000, }, }, { diff --git a/docs/specs/2026-07-07-micro-supabase-stacks-design.md b/docs/specs/2026-07-07-micro-supabase-stacks-design.md new file mode 100644 index 0000000000..a1e87fac94 --- /dev/null +++ b/docs/specs/2026-07-07-micro-supabase-stacks-design.md @@ -0,0 +1,211 @@ +# Micro Supabase Stacks — High-Density Local & Free-Tier Postgres + +- **Date:** 2026-07-07 +- **Status:** Draft for review +- **Scope:** Postgres first; architecture sized for the full minimal stack (Postgres, PostgREST, Auth, Realtime, Edge Functions) + +## Goal + +Run 100+ Supabase-compatible Postgres instances in parallel on small machines (8–16GB), for agents working in parallel git worktrees, local development, and very-low-resource free plans. "Compatible" means real upstream Postgres with the complete extension set of `supabase/postgres 17.6.1.143` — no PGlite-style reimplementation, no SQLite backend, no dropped extensions. + +## Requirements (agreed) + +- **Target environments:** both local dev machines (macOS/Linux) and Linux micro-VMs/containers for cloud free tier, from one shared design. +- **Compatibility bar:** every Supabase extension installable and behaviorally identical via `CREATE EXTENSION`; `shared_preload_libraries` trimmed to near-empty by default (heavy libraries load on demand). +- **Suspend-on-idle is acceptable:** first connection after idle may pay a wake-up latency of tens to hundreds of milliseconds. +- **Scope now:** Postgres and its orchestration. Sidecar services (PostgREST, Auth, Realtime, Edge Functions) are designed for but implemented later; their needs shape Postgres decisions today. +- **Durability:** data is disposable/re-cloneable everywhere (`fsync=off` profile). Recovery from host-crash corruption is "re-clone from template." +- **Density target:** 100+ registered instances on an 8–16GB host, most idle at any moment. +- **Windows:** `supabase start` must work, always via the Docker path (no native Windows service binaries). Windows gets functional parity, not the density optimizations — templates, suspend-on-idle, and the budget guardrails apply to macOS/Linux native pods. + +## Non-goals + +- Production/paid-tier deployments. +- Postgres major-version in-place upgrades (pods are disposable; reset onto a new base template). +- Windows *native* binaries and Windows density optimizations (`supabase start` still works on Windows via Docker; see Requirements). +- Slimming the sidecar services themselves (tracked as follow-up; see Risks). + +## Approach + +**Approach A — stock Postgres + aggressive configuration + a host-level orchestrator. No Postgres source patches.** + +Rejected alternatives: + +- **B — one cluster, database-per-worktree:** cheapest possible, but roles and `ALTER SYSTEM` are cluster-wide, `pg_cron` binds to one database, restart isolation is impossible, and it cannot model free-tier isolation. Kept in the back pocket for pure local agent swarms only. +- **C — patched "micro-mode" Postgres:** a fork of a fork (Supabase already patches Postgres) with every extension becoming a compatibility risk, for gains profiling suggests are small once suspend-on-idle and preload trimming are in place. Revisit only if Approach A hits a measured wall. + +Postgres's heavy reputation comes from default configs and Supabase's preload stack, not the engine. Tuned honestly, an idle instance costs ~15–25MB unique memory; suspended, it costs zero. + +## Architecture + +The deployment unit is a **pod**: one Postgres per worktree/project, with sidecars slotting in beside it later. A host is composed of: + +1. **Shared read-only artifacts (per host, not per pod):** + - One Postgres 17.6 install tree with the full Supabase extension set, built from the `supabase/postgres` Nix package definitions pinned to tag `17.6.1.143` (extension parity guaranteed, Supabase's own patches included, none of ours added). One artifact per platform: `aarch64-darwin`, `x86_64-linux`, `aarch64-linux`. All pods share the code pages. + - A **template store** (see Templates below). +2. **Per-pod state (cheap):** a copy-on-write clone of a template plus a generated conf overlay and allocated ports. +3. **One fleet daemon per host** — the orchestrator (see Fleet daemon below). + +**Suspend/resume operates on the pod as a unit, never on Postgres alone.** Realtime holds a logical-replication connection and PostgREST holds a pool; Postgres napping independently would look like an outage to them. Idle detection watches external traffic at the proxy edge and ignores intra-pod connections. + +### How the full-stack goal shapes Postgres decisions now + +- `wal_level=logical` with replication slots budgeted (Realtime), not `minimal`. +- `max_connections=40`: ~15 reserved for sidecar pools, ~25 for the user. +- Template pre-seeded with Supabase roles/schemas/baseline migrations so sidecars boot against a fresh clone without setup. +- `max_slot_wal_keep_size` capped so an undrained slot can never eat the disk. Because Realtime stops and starts with the pod, slots never dangle while WAL accumulates. +- Realtime (Elixir/BEAM, ~150–300MB) will dominate a full pod's memory. It is multi-tenant by design; the likely future is one shared Realtime serving all pods. Nothing here blocks either choice — slots are per-instance regardless. + +### Per-service lazy start + +Scale-to-zero applies at two levels: the pod, and each service within a warm pod. + +- The pod manifest declares which services are **enabled** (default: all, for cloud parity). Enabled means "may be started on demand," not "running." +- The gateway routes per path (`/rest/v1`, `/auth/v1`, `/realtime/v1`, `/functions/v1`), so the first request to a service starts exactly that service, health-gates, then forwards. Until then the pod runs without it. Postgres is the only member that always starts on pod wake. +- A request to a never-used service therefore still succeeds — indistinguishable from cloud except for first-request latency (PostgREST/GoTrue fast; Realtime seconds). +- **DB-originated traffic wakes services:** `pg_cron`/trigger-driven webhooks via `pg_net` arrive at the proxy edge and legitimately start/keep-alive services. Consequence (documented, intended): a pod with a scheduled workload never suspends. +- **Max capacity is a testing concern, not a runtime default:** CI verifies the all-services-warm envelope; runtime optimizes for the typical case. + +## Postgres build + +- Source of truth: `supabase/postgres` Nix package set at `17.6.1.143`, built unchanged, producing a relocatable install tree per platform. +- No size-oriented compile flags or feature stripping (ICU, SSL stay — compat-relevant). Binary size is a per-host cost. +- Locale/collation setup mirrors the Supabase image exactly (provider and `C.UTF-8` defaults) to preserve dump/restore fidelity. +- **First implementation spike:** validate the Nix build of the full extension set on `aarch64-darwin`. Documented fallback: run Linux pods in a lightweight VM on macOS. + +## The `micro.conf` profile + +Everything not listed stays at PG defaults. + +**Memory:** + +| Setting | Value | Rationale | +|---|---|---| +| `shared_buffers` | `16MB` | Dev datasets are small; the OS page cache (shared across pods) backs reads; only touched pages cost RSS. | +| `work_mem` | `4MB` | Allocated only while sorting; safe ceiling. | +| `maintenance_work_mem` | `32MB` | Allocated only during vacuum/index build. | +| `jit` | `off` | Never loads LLVM into backends; the single biggest per-backend saving, useless on dev-sized data. | +| `huge_pages` | `off` | Irrelevant at this scale. | +| `max_connections` | `40` | Slots are cheap; only spawned backends (~2–5MB) cost real memory. | + +**Background CPU:** `autovacuum_naptime=5min`, `autovacuum_max_workers=1`, `bgwriter_lru_maxpages=0`, `walwriter_delay=10s`, `checkpoint_timeout=30min`, `max_parallel_workers=0`, `max_parallel_workers_per_gather=0`, `max_worker_processes=4` (headroom for on-demand `pg_cron`/`pg_net`/timescale workers), `track_io_timing=off`, minimal logging to a per-pod file. + +**Durability (disposable profile):** `fsync=off`, `synchronous_commit=off`, `full_page_writes=off`. Also makes startup/shutdown fast, which suspend/resume relies on. Host-crash corruption recovery: `reset` (re-clone). + +**Replication:** `wal_level=logical`, `max_wal_senders=5`, `max_replication_slots=5`, `max_slot_wal_keep_size=256MB`, `wal_keep_size=0`. + +### Preload policy: preload on request + +`shared_preload_libraries` starts **empty**. Extensions that require preload (`pg_cron`, `pg_net`, `timescaledb`, `pg_stat_statements`, `auto_explain`, `pgaudit`, `plan_filter`, `supautils`, legacy `pgsodium`) are handled by an orchestrator API `ensure-extension-preload `: append the library to the pod's conf overlay, restart that pod's Postgres (sub-second with `fsync=off`), then `CREATE EXTENSION` works. All other extensions (`pgvector`, `pg_graphql`, `postgis`, `pgjwt`, `vault`, …) are plain `CREATE EXTENSION`, zero cost until used. `supautils` (platform role guardrails) is a profile flag: off for local dev, on for free-tier fidelity. + +### Config layering + +Per-pod `postgresql.conf` = `include micro.conf` (shared, read-only) + generated per-pod overlay (port, socket dir, preloads). User `ALTER SYSTEM` lands in `postgresql.auto.conf` inside the pod's data dir, surviving suspend/resume and matching real-project semantics. Auth via scram with the standard local-dev password, matching Supabase CLI conventions. + +## Templates & provisioning + +### Pod manifest + +Small declarative file stored with the pod (versions illustrative; defaults come from the stack package's version manifest): + +```yaml +id: worktree-nifty-dhawan +versions: { postgres: 17.6.1.143, auth: 2.177.0, realtime: 2.34.47, postgrest: 13.0.4, functions: 1.67.0 } +services: { rest: enabled, auth: enabled, realtime: enabled, functions: enabled } +flags: { supautils: off } +``` + +The `versions` tuple picks binaries, keys the warm-template cache, and tells the runtime what to run. + +### Template store (per host) + +Each service owns and applies its own migrations at boot (GoTrue → `auth`, Realtime → `realtime`, Storage → `storage`); the Postgres image ships only the baseline. Hence two layers: + +- **Base template**, tagged by Postgres image version only (`pg-17.6.1.143`): `initdb` with image-matching locale settings, boot, apply the `supabase/postgres` baseline migrations (roles: `anon`, `authenticated`, `service_role`, `supabase_admin`, …; schemas: `auth`, `realtime`, `storage`, `extensions`; default extensions), clean shutdown, mark read-only. This alone guarantees any combination of service versions can boot against a fresh clone — each service self-migrates exactly as it would against a cloud project. +- **Warm templates**, keyed by the full version-tuple hash, built lazily: on first provision of a new tuple, clone base → boot the full stack once → services self-migrate → shutdown → freeze as a cached template. Subsequent pods with the same tuple skip migration time entirely (first boot drops from seconds to clone cost). Pure optimization — cache miss falls back to the base path. LRU garbage-collected. + +**Service upgrades on an existing pod need no template machinery:** bump the manifest tuple; the new service version self-migrates the pod's live data dir on next boot, as in production. Templates matter only at provisioning time; a pod is never re-cloned under a live data directory. + +### Provisioning (fast path) + +1. Clone template → `pods//data` via `clonefile` (APFS) / `cp --reflink=auto` (btrfs/xfs) / plain copy fallback (template ~40–50MB, so ~a second worst case). +2. Write the per-pod conf overlay; register pod + ports with the fleet daemon. +3. Done — Postgres doesn't start until the first connection. Provision cost: milliseconds and near-zero bytes. + +### Lifecycle operations + +- `create` / `destroy` (destroy releases ports, reclaims disk). +- `reset` — re-clone from template; doubles as corruption recovery. +- `fork ` — CoW-clone an existing pod's data dir (after a brief clean suspend of the source) into a new pod. Agents branching a worktree get a byte-identical, independently-diverging database branch in milliseconds. +- `upgrade` — edit the manifest tuple; services self-migrate on next boot. + +On-disk layout follows the existing `~/.supabase` convention (where the binary cache already lives): `~/.supabase/{templates, pods//{data, conf, logs, run}}` — a single inspectable, deletable tree. + +### Addressing + +- **Postgres:** one TCP port per pod from an orchestrator-allocated range (the pgwire protocol has no pre-TLS host routing to exploit). +- **HTTP services:** one shared gateway port, host/path-routed (`.localhost:/rest/v1/…`) — also where lazy start hooks in. On cloud free tier, where pods have their own network identity, the same proxy binds per-pod addresses. +- Connection strings printed by the CLI match Supabase-CLI conventions. + +## Orchestrator: evolve `@supabase/stack` with a Fleet module + +`@supabase/stack` (in the Bun CLI monorepo, `packages/stack`) is viable and is kept. It already provides: native-binary resolution with Docker fallback (shared cache at `~/.supabase/bin` — which *is* the shared install tree), dependency-ordered supervision via `@supabase/process-compose`, health-gated readiness, the key-translating API proxy, per-service `startService`/`stopService` (the substrate lazy start needs), port allocation, daemon/connect modes, and parallel-stack E2E tests. + +### Division of responsibilities + +- **`@supabase/stack` = pod runtime.** "Given a prepared data dir and version manifest, run this stack": ServiceDefs, supervision, health checks, API proxy, per-service start/stop, log streaming. +- **Fleet = host-level module exported from `@supabase/stack/fleet`** owning everything that must exist when pods don't: pod registry and manifests, template store + CoW provisioning + `fork`, persistent port registry, the always-listening network edge, idle timers, wake/suspend policy. A CLI daemon hosts warm pods as in-process `StackHandle`s inside one Bun process. The programmatic TypeScript interface (`fleet.createPod()`, `fleet.wake()`, `fleet.forkPod()`, `fleet.suspend()`) remains explicit; `createStack()` does not implicitly discover or launch a global daemon. + +This replaces the current daemon-per-stack fork model (100 pods must not mean 100 daemons, and a suspended pod must cost zero processes — only a shared daemon holding its port delivers that) and replaces `readReservedPorts()`'s per-create filesystem scan with an owned registry. + +### Suspend/resume mechanics + +- **Edge:** the fleet daemon permanently binds every pod's Postgres TCP port and the shared HTTP gateway. Postgres traffic is spliced TCP (per-port; negligible overhead for dev). HTTP routes by host/path as ApiProxy does today. +- **Wake:** connection to a suspended pod's port → start Postgres (~100–300ms with `fsync=off`) → forward. First HTTP request to a not-yet-running service on a warm pod → `stack.startService(name)` → health-gate → forward. +- **Idle:** suspend when a pod has no open external connections **and** no bytes flowing for T (per-profile: 5min local dev, 15min free tier). Suspend = `stack.stop()` (graceful, dependency-ordered, already implemented). Live Realtime websockets correctly keep a pod warm; `pg_cron`-driven pods correctly never sleep. +- **Crash handling:** pods run as detached process groups with pidfiles; the fleet daemon **adopts** running pods on restart via liveness checks (the pattern `StateManager` already uses, promoted to fleet level). Daemon crash ≠ database outage. Postgres crash → process-compose restart policy; unrecoverable data dir → `reset`. + +### Changes inside `@supabase/stack` + +1. **Provision via template clone, not per-boot init:** `postgres-init` reduces to a no-op check; the fleet daemon hands `createProvisionedStack` a pre-cloned data dir and declarative version/service selection. Stack owns the runtime configuration. (Per-boot init can never support `fork`; templates can.) +2. **Micro config profile:** the Postgres service gains the conf-overlay mechanism and an `ensureExtensionPreload(name)` interface that persists the required preload and restarts Postgres when necessary. +3. **Version bump `17.6.1.081 → 17.6.1.143`; native-first hardening.** Roadmap flag (not this project): Edge Runtime is currently Docker-forced; native bundles for remaining services are the ask to service teams — Docker-only services undercut the density story on tiny machines. + +Untouched: Effect-based internals, BinaryResolver, health checks, key-translation proxy, log streaming. + +## Memory budget, measurement & testing + +### Methodology + +- **Memory = PSS** (not RSS), summed over the pod's process group: `/proc//smaps_rollup` on Linux, `phys_footprint` on macOS. RSS double-counts shared binary pages ~100× across the fleet; PSS attributes them fractionally, which is what actually fills the host. +- **CPU = idle wakeups + %core over 60s** per warm pod (`powermetrics` / `pidstat`). Idle CPU is what melts a laptop at 20 warm pods; Section "Background CPU" settings exist for this number. + +### Budget guardrails (per pod, to be validated by the harness) + +| State | Memory (PSS) | CPU idle | +|---|---|---| +| Suspended | 0 (manifest on disk only) | 0 | +| Warm, Postgres only | ≤ 30MB | < 0.5% core | +| Typical warm (PG + PostgREST + Auth) | ≤ 120MB | < 1% core | +| Max capacity (all services incl. Realtime + Functions) | ≤ 450MB | measured, documented | + +Host math: an 8GB machine holds 100+ registered pods with ~15–20 typical-warm or ~8–10 max-warm concurrent — past the density target. Realtime dominates the full-pod number (see Risks). + +### Benchmark harness + +A script in the CLI repo: provision N pods, wake a subset, drive synthetic traffic, sample PSS/CPU, assert the table above. Runs in CI so a version bump or config change that blows the envelope fails a build. + +### Tests + +- **Unit:** port registry, template cache keys (tuple hashing), idle-timer state machine, manifest round-trips. +- **Integration:** base/warm template builds; clone and `fork` divergence (write to fork, source untouched); extension-preload configuration end-to-end; suspend/resume preserves committed data; wake-latency assertions. +- **Compatibility suite (the "no compromise on extensions" proof):** `CREATE EXTENSION` for every extension in `supabase/postgres 17.6.1.143` plus a smoke query each; real CLI migration/seed flows; round-trip fidelity (`pg_dump` from pod → restore into stock image, and vice versa); logical-replication smoke test (create slot, stream changes) standing in for Realtime until sidecars land. +- **Density E2E:** extend `parallelStacks.e2e` — 100 registered pods, wake 10 at random, assert envelope, port uniqueness, clean suspend. +- **Chaos:** `kill -9` fleet daemon → adoption on restart; kill Postgres → supervised restart; simulated host crash → verify documented `reset` recovery. + +## Risks & open questions + +1. **Nix darwin builds** of some extensions in the `supabase/postgres` set may not compile on `aarch64-darwin`. First implementation spike; fallback is Linux pods in a lightweight VM on macOS. +2. **Realtime memory per pod** (~150–300MB BEAM VM) dominates full pods. Mitigated now by lazy start; the structural fix is one shared multi-tenant Realtime per host — future project, not blocked by this design. +3. **Docker-only services** (Edge Runtime today) undercut density on tiny machines; native bundles are a roadmap ask to service teams. +4. **Windows:** served by the Docker path for functional parity (`supabase start` works); explicitly out of the density story. The fleet daemon must degrade gracefully there — pods run as Docker containers without templates or suspend-on-idle. diff --git a/docs/superpowers/plans/2026-07-07-micro-stacks-phase1-stack-and-fleet.md b/docs/superpowers/plans/2026-07-07-micro-stacks-phase1-stack-and-fleet.md new file mode 100644 index 0000000000..7de3eb7aeb --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-micro-stacks-phase1-stack-and-fleet.md @@ -0,0 +1,2269 @@ +# Micro Stacks Phase 1 — `@supabase/stack` Changes + `@supabase/fleet` Daemon + +> **Implementation note:** Fleet was ultimately folded into the Stack package and is exported from +> `@supabase/stack/fleet`. Paths and package names below describe the original implementation plan; +> see `packages/stack/docs/fleet.md` for the current interface and ownership model. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `@supabase/stack` pods template-provisioned, micro-tuned, and lazily started, and add a new `@supabase/fleet` package: a host-level daemon giving 100+ registered pods with suspend-on-idle, wake-on-connect, and CoW fork/reset. + +**Architecture:** `@supabase/stack` stays the pod runtime (supervise processes for one stack); `@supabase/fleet` is a new package owning what exists when pods don't: pod/port registries, template store, CoW provisioning, a TCP wake-proxy edge, and idle timers. Fleet hosts warm pods as in-process `StackHandle`s. Spec: `docs/specs/2026-07-07-micro-supabase-stacks-design.md` (in this repo). + +**Tech Stack:** TypeScript on Bun, Effect 4 (beta, via pnpm `catalog:`), vitest, nx, `node:net` for TCP splicing (works on Bun and Node), `node:crypto` for hashing. + +## Global Constraints + +- Work in `/Users/jgoux/Code/supabase/cli/.claude/worktrees/micro-supabase-stacks-spec`, branch `claude/nifty-dhawan-86e0c6`'s sibling `claude/micro-supabase-stacks-spec`. Never commit to `develop`. +- `@supabase/stack` is unpublished — breaking API changes are allowed. +- All new deps via `catalog:` in `pnpm-workspace.yaml`; run `pnpm install` after editing any package.json. +- Test naming: `*.unit.test.ts` (no I/O), `*.integration.test.ts` (may spawn postgres), `*.e2e.test.ts` (full stacks, serial). Runner: vitest via nx (`pnpm vitest run ` works directly inside a package). +- Effect style: `Context.Service` classes, `Layer`, `Effect.gen`; public APIs are Promise-based wrappers via `ManagedRuntime` (mirror `createStack.ts:694-763`). +- Postgres micro profile values are normative from the spec — copy exactly (note the real GUC name is `wal_writer_delay`). +- On-disk layout: `~/.supabase/templates//data`, `~/.supabase/pods//{data,pod.json,logs}`, `~/.supabase/fleet-state.json` (tests must override the root via options — never touch the real `~/.supabase` in tests; use `mkdtemp`). +- Commit after every task with a conventional-commit message. +- Windows: fleet features are macOS/Linux native-mode only in this phase; `mode: "docker"` paths must keep compiling and existing tests must keep passing. + +--- + +### Task 1: Scaffold `@supabase/fleet` package + +**Files:** +- Create: `packages/fleet/package.json` +- Create: `packages/fleet/tsconfig.json` +- Create: `packages/fleet/src/index.ts` +- Test: `packages/fleet/src/index.unit.test.ts` + +**Interfaces:** +- Produces: an empty package `@supabase/fleet` that later tasks fill; exports nothing yet but `FLEET_PACKAGE` marker for the scaffold test. + +- [ ] **Step 1: Write package.json** (mirror `packages/cli-test-helpers/package.json` conventions) + +```json +{ + "name": "@supabase/fleet", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "nx run-many -t test:core --projects=$npm_package_name", + "check:all": "nx run-many -t types:check lint:check fmt:check knip:check --projects=$npm_package_name", + "fix:all": "nx run-many -t lint:fix fmt:fix knip:fix --projects=$npm_package_name" + }, + "dependencies": { + "@supabase/process-compose": "workspace:*", + "@supabase/stack": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@vitest/coverage-istanbul": "catalog:", + "knip": "catalog:", + "oxfmt": "catalog:", + "oxlint": "catalog:", + "vitest": "catalog:" + }, + "knip": { + "entry": ["src/**/*.test.ts"], + "ignoreDependencies": ["@typescript/native-preview", "oxfmt", "oxlint", "oxlint-tsgolint"], + "ignoreBinaries": ["nx"] + } +} +``` + +- [ ] **Step 2: Write tsconfig.json** + +```json +{ + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "types": ["bun"] + } +} +``` + +- [ ] **Step 3: Write src/index.ts and a smoke test** + +```typescript +// src/index.ts +export const FLEET_PACKAGE = "@supabase/fleet"; +``` + +```typescript +// src/index.unit.test.ts +import { describe, expect, it } from "vitest"; +import { FLEET_PACKAGE } from "./index.ts"; + +describe("fleet scaffold", () => { + it("exports the package marker", () => { + expect(FLEET_PACKAGE).toBe("@supabase/fleet"); + }); +}); +``` + +- [ ] **Step 4: Install and run** + +Run: `pnpm install && cd packages/fleet && pnpm vitest run src/index.unit.test.ts` +Expected: 1 passed. + +- [ ] **Step 5: Commit** + +```bash +git add packages/fleet pnpm-lock.yaml +git commit -m "feat(fleet): scaffold @supabase/fleet package" +``` + +--- + +### Task 2: Micro profile module in `@supabase/stack` + +**Files:** +- Create: `packages/stack/src/micro.ts` +- Test: `packages/stack/src/micro.unit.test.ts` + +**Interfaces:** +- Produces: + - `MICRO_POSTGRES_SETTINGS: ReadonlyArray` + - `buildMicroConf(): string` — full `micro.conf` file content + - `PRELOAD_REQUIRED_EXTENSIONS: ReadonlySet` + - `buildPodConf(preloadLibraries: ReadonlyArray): string` + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/micro.unit.test.ts +import { describe, expect, it } from "vitest"; +import { + buildMicroConf, + buildPodConf, + MICRO_POSTGRES_SETTINGS, + PRELOAD_REQUIRED_EXTENSIONS, +} from "./micro.ts"; + +describe("micro profile", () => { + it("contains the normative spec settings", () => { + const map = new Map(MICRO_POSTGRES_SETTINGS); + expect(map.get("shared_buffers")).toBe("16MB"); + expect(map.get("jit")).toBe("off"); + expect(map.get("fsync")).toBe("off"); + expect(map.get("wal_level")).toBe("logical"); + expect(map.get("max_slot_wal_keep_size")).toBe("256MB"); + expect(map.get("wal_writer_delay")).toBe("10s"); + }); + + it("renders micro.conf as key = 'value' lines", () => { + const conf = buildMicroConf(); + expect(conf).toContain("shared_buffers = '16MB'"); + expect(conf).toContain("max_connections = '40'"); + expect(conf.endsWith("\n")).toBe(true); + }); + + it("knows which extensions need preload", () => { + expect(PRELOAD_REQUIRED_EXTENSIONS.has("pg_cron")).toBe(true); + expect(PRELOAD_REQUIRED_EXTENSIONS.has("pgvector")).toBe(false); + }); + + it("renders pod.conf with shared_preload_libraries", () => { + expect(buildPodConf(["pg_cron", "pg_net"])).toBe( + "shared_preload_libraries = 'pg_cron,pg_net'\n", + ); + expect(buildPodConf([])).toBe("shared_preload_libraries = ''\n"); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/stack && pnpm vitest run src/micro.unit.test.ts` +Expected: FAIL — cannot resolve `./micro.ts`. + +- [ ] **Step 3: Implement** + +```typescript +// src/micro.ts +/** + * Micro profile: normative values from + * docs/specs/2026-07-07-micro-supabase-stacks-design.md ("The micro.conf profile"). + */ +export const MICRO_POSTGRES_SETTINGS: ReadonlyArray = [ + // memory + ["shared_buffers", "16MB"], + ["work_mem", "4MB"], + ["maintenance_work_mem", "32MB"], + ["jit", "off"], + ["huge_pages", "off"], + ["max_connections", "40"], + // background CPU + ["autovacuum_naptime", "5min"], + ["autovacuum_max_workers", "1"], + ["bgwriter_lru_maxpages", "0"], + ["wal_writer_delay", "10s"], + ["checkpoint_timeout", "30min"], + ["max_parallel_workers", "0"], + ["max_parallel_workers_per_gather", "0"], + ["max_worker_processes", "4"], + ["track_io_timing", "off"], + // durability (disposable profile) + ["fsync", "off"], + ["synchronous_commit", "off"], + ["full_page_writes", "off"], + // replication reservations + ["wal_level", "logical"], + ["max_wal_senders", "5"], + ["max_replication_slots", "5"], + ["max_slot_wal_keep_size", "256MB"], + ["wal_keep_size", "0"], +]; + +export const buildMicroConf = (): string => + `${MICRO_POSTGRES_SETTINGS.map(([k, v]) => `${k} = '${v}'`).join("\n")}\n`; + +/** Extensions that only work when named in shared_preload_libraries. */ +export const PRELOAD_REQUIRED_EXTENSIONS: ReadonlySet = new Set([ + "pg_cron", + "pg_net", + "timescaledb", + "pg_stat_statements", + "auto_explain", + "pgaudit", + "plan_filter", + "supautils", + "pgsodium", +]); + +export const buildPodConf = (preloadLibraries: ReadonlyArray): string => + `shared_preload_libraries = '${preloadLibraries.join(",")}'\n`; +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/stack && pnpm vitest run src/micro.unit.test.ts` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add packages/stack/src/micro.ts packages/stack/src/micro.unit.test.ts +git commit -m "feat(stack): add micro postgres profile and preload-required registry" +``` + +--- + +### Task 3: PGDATA conf-layering utilities + +**Files:** +- Create: `packages/stack/src/pgconf.ts` +- Test: `packages/stack/src/pgconf.unit.test.ts` + +**Interfaces:** +- Consumes: `buildMicroConf`, `buildPodConf` from Task 2. +- Produces (all plain async, `node:fs/promises` — used by both stack and fleet): + - `installMicroProfile(pgdata: string): Promise` — writes `micro.conf` + empty `pod.conf` into PGDATA and appends include lines to `postgresql.conf` once (idempotent) + - `readPreloadLibraries(pgdata: string): Promise` + - `writePreloadLibraries(pgdata: string, libs: ReadonlyArray): Promise` + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/pgconf.unit.test.ts +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + installMicroProfile, + readPreloadLibraries, + writePreloadLibraries, +} from "./pgconf.ts"; + +async function fakePgdata(): Promise { + const dir = await mkdtemp(join(tmpdir(), "pgconf-test-")); + await writeFile(join(dir, "postgresql.conf"), "# stock conf\nport = 5432\n"); + return dir; +} + +describe("pgconf", () => { + it("installs micro.conf, pod.conf, and include lines idempotently", async () => { + const pgdata = await fakePgdata(); + await installMicroProfile(pgdata); + await installMicroProfile(pgdata); // idempotent + const main = await readFile(join(pgdata, "postgresql.conf"), "utf8"); + expect(main.match(/include_if_exists = 'micro\.conf'/g)).toHaveLength(1); + expect(main.match(/include_if_exists = 'pod\.conf'/g)).toHaveLength(1); + // pod.conf must be included AFTER micro.conf so pod overrides micro + expect(main.indexOf("micro.conf")).toBeLessThan(main.indexOf("pod.conf")); + const micro = await readFile(join(pgdata, "micro.conf"), "utf8"); + expect(micro).toContain("shared_buffers = '16MB'"); + }); + + it("round-trips preload libraries via pod.conf", async () => { + const pgdata = await fakePgdata(); + await installMicroProfile(pgdata); + expect(await readPreloadLibraries(pgdata)).toEqual([]); + await writePreloadLibraries(pgdata, ["pg_cron"]); + expect(await readPreloadLibraries(pgdata)).toEqual(["pg_cron"]); + await writePreloadLibraries(pgdata, ["pg_cron", "pg_net"]); + expect(await readPreloadLibraries(pgdata)).toEqual(["pg_cron", "pg_net"]); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/stack && pnpm vitest run src/pgconf.unit.test.ts` +Expected: FAIL — cannot resolve `./pgconf.ts`. + +- [ ] **Step 3: Implement** + +```typescript +// src/pgconf.ts +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { buildMicroConf, buildPodConf } from "./micro.ts"; + +const INCLUDE_BLOCK = [ + "", + "# --- supabase micro profile (managed; do not edit below) ---", + "include_if_exists = 'micro.conf'", + "include_if_exists = 'pod.conf'", + "", +].join("\n"); + +export async function installMicroProfile(pgdata: string): Promise { + await writeFile(join(pgdata, "micro.conf"), buildMicroConf()); + const podConf = join(pgdata, "pod.conf"); + const existing = await readFile(podConf, "utf8").catch(() => undefined); + if (existing === undefined) { + await writeFile(podConf, buildPodConf([])); + } + const mainPath = join(pgdata, "postgresql.conf"); + const main = await readFile(mainPath, "utf8"); + if (!main.includes("include_if_exists = 'micro.conf'")) { + await writeFile(mainPath, main + INCLUDE_BLOCK); + } +} + +export async function readPreloadLibraries(pgdata: string): Promise { + const content = await readFile(join(pgdata, "pod.conf"), "utf8").catch(() => ""); + const match = content.match(/^shared_preload_libraries = '([^']*)'/m); + if (!match || match[1] === "") return []; + return match[1].split(","); +} + +export async function writePreloadLibraries( + pgdata: string, + libs: ReadonlyArray, +): Promise { + await writeFile(join(pgdata, "pod.conf"), buildPodConf(libs)); +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/stack && pnpm vitest run src/pgconf.unit.test.ts` +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add packages/stack/src/pgconf.ts packages/stack/src/pgconf.unit.test.ts +git commit -m "feat(stack): PGDATA conf layering (micro.conf + pod.conf includes)" +``` + +--- + +### Task 4: `provisioned` + `profile` on PostgresConfig; skip init; version bump + +**Files:** +- Modify: `packages/stack/src/StackBuilder.ts` (PostgresConfig ~line 42-169; service assembly ~line 556-870) +- Modify: `packages/stack/src/services/postgres.ts` (NATIVE_POSTGRES_RUNTIME_ARGS, lines 44-51) +- Modify: `packages/stack/src/versions.ts` (DEFAULT_VERSIONS, line ~49) +- Test: `packages/stack/src/StackBuilder.provisioned.unit.test.ts` + +**Interfaces:** +- Consumes: nothing new. +- Produces: + - `PostgresConfig` gains `readonly provisioned?: boolean` (data dir is a pre-initialized template clone → **exclude the `postgres-init` service entirely**) and `readonly profile?: "default" | "micro"`. + - When `profile: "micro"`, the native postgres ServiceDef gets NO `-c` runtime args (micro.conf in PGDATA carries them; command-line `-c` would override users' `ALTER SYSTEM`). When `profile` is absent/default, current behavior is unchanged. + - `DEFAULT_VERSIONS.postgres` becomes `"17.6.1.143"`. + +- [ ] **Step 1: Write the failing test** + +Find the existing StackBuilder unit tests for reference on constructing a `ResolvedStackConfig`/builder in isolation (see `packages/stack/tests/` and any `StackBuilder*.test.ts`; follow the same fixture helpers). The test asserts on the built ServiceDef list: + +```typescript +// src/StackBuilder.provisioned.unit.test.ts +// NOTE: reuse the existing StackBuilder test fixture pattern in this package for +// constructing a builder; the assertions below are the contract. +import { describe, expect, it } from "vitest"; +import { buildServicesForTest } from "../tests/helpers/buildServices.ts"; // create if absent, wrapping StackBuilder.build() with a minimal ResolvedStackConfig fixture + +describe("provisioned postgres", () => { + it("excludes postgres-init when postgres.provisioned is true", async () => { + const services = await buildServicesForTest({ postgres: { provisioned: true } }); + expect(services.map((s) => s.name)).not.toContain("postgres-init"); + }); + + it("includes postgres-init by default", async () => { + const services = await buildServicesForTest({}); + expect(services.map((s) => s.name)).toContain("postgres-init"); + }); + + it("drops -c runtime args when profile is micro", async () => { + const services = await buildServicesForTest({ + postgres: { provisioned: true, profile: "micro" }, + }); + const pg = services.find((s) => s.name === "postgres"); + expect(pg?.args?.join(" ")).not.toContain("wal_level=logical"); + }); + + it("keeps -c runtime args on the default profile", async () => { + const services = await buildServicesForTest({}); + const pg = services.find((s) => s.name === "postgres"); + expect(pg?.args?.join(" ")).toContain("wal_level=logical"); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/stack && pnpm vitest run src/StackBuilder.provisioned.unit.test.ts` +Expected: FAIL — `provisioned` not a known property / init service always present. + +- [ ] **Step 3: Implement** + +In `StackBuilder.ts`, extend the config interface: + +```typescript +export interface PostgresConfig { + readonly port?: number; + readonly dataDir?: string; + readonly version?: string; + readonly autoExposeNewTables?: boolean; + /** Data dir is a pre-initialized template clone; skip the postgres-init service. */ + readonly provisioned?: boolean; + /** "micro": settings come from micro.conf/pod.conf inside PGDATA, not -c args. */ + readonly profile?: "default" | "micro"; +} +``` + +In the service-assembly section, wrap the `postgres-init` push: + +```typescript +if (config.postgres?.provisioned !== true) { + services.push({ ...makePostgresInitService(postgresInitOpts), enabled: true }); +} +``` + +In `services/postgres.ts`, thread the profile through `NativePostgresOptions` and select args: + +```typescript +export interface NativePostgresOptions { + // ...existing fields... + readonly profile?: "default" | "micro"; +} + +const runtimeArgs = (profile?: "default" | "micro"): readonly string[] => + profile === "micro" ? [] : NATIVE_POSTGRES_RUNTIME_ARGS; + +// in makePostgresService: +args: [initScript, "-p", String(opts.port), ...runtimeArgs(opts.profile)], +``` + +In `versions.ts`: `postgres: "17.6.1.143",`. + +- [ ] **Step 4: Run to verify pass, plus existing suites** + +Run: `cd packages/stack && pnpm vitest run src/StackBuilder.provisioned.unit.test.ts && pnpm vitest run --exclude '**/*.e2e.test.ts'` +Expected: new tests pass; no regressions in unit/integration suites. + +- [ ] **Step 5: Commit** + +```bash +git add packages/stack/src packages/stack/tests +git commit -m "feat(stack): provisioned data dirs, micro profile wiring, postgres 17.6.1.143" +``` + +--- + +### Task 5: `ensureExtensionPreload` on the coordinator and StackHandle + +**Files:** +- Modify: `packages/stack/src/StackLifecycleCoordinator.ts` (service methods, ~lines 131-171 and 518-564) +- Modify: `packages/stack/src/createStack.ts` (StackHandle interface ~line 90-111 and Promise wiring ~line 694-763) +- Modify: `packages/stack/src/index.ts` (export new types) +- Test: `packages/stack/src/extensionPreload.unit.test.ts` + +**Interfaces:** +- Consumes: `PRELOAD_REQUIRED_EXTENSIONS` (Task 2), `readPreloadLibraries`/`writePreloadLibraries` (Task 3), coordinator `restartService` (existing). +- Produces: + - Coordinator: `readonly ensureExtensionPreload: (name: string) => Effect.Effect` + - `StackHandle.ensureExtensionPreload(name: string): Promise` + - Behavior: if `name` is not in `PRELOAD_REQUIRED_EXTENSIONS` → no-op (plain `CREATE EXTENSION` works). If already in pod.conf → no-op. Else append to pod.conf and `restartService("postgres")`. + +- [ ] **Step 1: Write the failing test** + +Test the pure decision logic separately from the Effect plumbing so it runs without postgres: + +```typescript +// src/extensionPreload.unit.test.ts +import { describe, expect, it } from "vitest"; +import { planExtensionPreload } from "./extensionPreload.ts"; + +describe("planExtensionPreload", () => { + it("no-ops for extensions that do not need preload", () => { + expect(planExtensionPreload("pgvector", [])).toEqual({ action: "none" }); + }); + it("no-ops when already preloaded", () => { + expect(planExtensionPreload("pg_cron", ["pg_cron"])).toEqual({ action: "none" }); + }); + it("appends the required library otherwise", () => { + expect(planExtensionPreload("pg_cron", ["pg_net"])).toEqual({ + action: "update", + libraries: ["pg_net", "pg_cron"], + }); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/stack && pnpm vitest run src/extensionPreload.unit.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```typescript +// src/extensionPreload.ts +import { PRELOAD_REQUIRED_EXTENSIONS } from "./micro.ts"; + +export type ExtensionPreloadPlan = + | { readonly action: "none" } + | { readonly action: "update"; readonly libraries: ReadonlyArray }; + +export const planExtensionPreload = ( + name: string, + currentLibraries: ReadonlyArray, +): ExtensionPreloadPlan => { + if (!PRELOAD_REQUIRED_EXTENSIONS.has(name)) return { action: "none" }; + if (currentLibraries.includes(name)) return { action: "none" }; + return { action: "update", libraries: [...currentLibraries, name] }; +}; +``` + +In `StackLifecycleCoordinator.ts`, add to the service interface and implementation (the coordinator already knows the resolved postgres `dataDir` from config): + +```typescript +ensureExtensionPreload: (name: string) => + Effect.gen(function* () { + const libs = yield* Effect.promise(() => readPreloadLibraries(pgDataDir)); + const plan = planExtensionPreload(name, libs); + if (plan.action === "none") return; + yield* Effect.promise(() => writePreloadLibraries(pgDataDir, plan.libraries)); + yield* restartServiceImpl("postgres"); + }), +``` + +In `createStack.ts`, add to `StackHandle` and the wiring: + +```typescript +ensureExtensionPreload(name: string): Promise; +// ... +ensureExtensionPreload: (name) => run(localStack.ensureExtensionPreload(name)), +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/stack && pnpm vitest run src/extensionPreload.unit.test.ts && pnpm vitest run --exclude '**/*.e2e.test.ts'` +Expected: PASS, no regressions. + +- [ ] **Step 5: Commit** + +```bash +git add packages/stack/src +git commit -m "feat(stack): configure extension preloads on demand" +``` + +--- + +### Task 6: Lazy service start behind the ApiProxy + +**Files:** +- Modify: `packages/stack/src/StackBuilder.ts` (StackConfig + service `enabled` flags) +- Modify: `packages/stack/src/ApiProxy.ts` (ProxyHandlerOptions + makeProxyHandler, lines ~13-202; route table ~218-365) +- Test: `packages/stack/src/lazyServices.unit.test.ts` + +**Interfaces:** +- Consumes: coordinator `startService(name)` (existing), process-compose `ServiceDef.enabled` (existing). +- Produces: + - `StackConfig` gains `readonly lazyServices?: boolean` (default false → existing behavior). + - When true: every service except `postgres` (and `postgres-init` when present) is built with `enabled: false`; ApiProxy's `ProxyConfig` gains `readonly ensureService?: (name: ServiceName) => Promise`; each route entry declares its owning `service: ServiceName`; the handler awaits `ensureService(service)` before forwarding (idempotent, resolves immediately if running). + - `createStack` wires `ensureService` to `startService` + `serviceReady`, memoizing in-flight starts so concurrent first requests trigger one start. + +- [ ] **Step 1: Write the failing test** (route-level behavior with a fake ensureService) + +```typescript +// src/lazyServices.unit.test.ts +import { describe, expect, it } from "vitest"; +import { makeEnsureServiceMemo } from "./lazyServices.ts"; + +describe("makeEnsureServiceMemo", () => { + it("starts a service once across concurrent calls", async () => { + let starts = 0; + const ensure = makeEnsureServiceMemo(async (name) => { + starts += 1; + await new Promise((r) => setTimeout(r, 10)); + }); + await Promise.all([ensure("realtime"), ensure("realtime"), ensure("realtime")]); + expect(starts).toBe(1); + }); + + it("retries after a failed start", async () => { + let attempt = 0; + const ensure = makeEnsureServiceMemo(async () => { + attempt += 1; + if (attempt === 1) throw new Error("boom"); + }); + await expect(ensure("auth")).rejects.toThrow("boom"); + await ensure("auth"); // second attempt allowed + expect(attempt).toBe(2); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/stack && pnpm vitest run src/lazyServices.unit.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```typescript +// src/lazyServices.ts +import type { ServiceName } from "./versions.ts"; + +/** Memoize in-flight starts; clear on failure so the next request retries. */ +export const makeEnsureServiceMemo = ( + start: (name: ServiceName) => Promise, +): ((name: ServiceName) => Promise) => { + const inFlight = new Map>(); + const done = new Set(); + return (name) => { + if (done.has(name)) return Promise.resolve(); + const existing = inFlight.get(name); + if (existing) return existing; + const p = start(name).then( + () => { + done.add(name); + inFlight.delete(name); + }, + (err: unknown) => { + inFlight.delete(name); + throw err; + }, + ); + inFlight.set(name, p); + return p; + }; +}; +``` + +In `StackBuilder.ts`: add `readonly lazyServices?: boolean` to `StackConfig`; where each non-postgres service is pushed, compute `enabled: config.lazyServices === true ? false : `. + +In `ApiProxy.ts`: add to `ProxyConfig` `readonly ensureService?: (name: ServiceName) => Promise`; extend `ProxyHandlerOptions` with `readonly service: ServiceName`; annotate every route in the table with its service (`/auth/v1` → `"auth"`, `/rest/v1` → `"postgrest"`, `/functions/v1` → `"edge-runtime"`, `/realtime/v1` → `"realtime"`, `/storage/v1` → `"storage"`, `/pg` → `"pgmeta"`, etc. — follow the routing table); at the top of `makeProxyHandler`'s returned effect: + +```typescript +if (config.ensureService) { + yield* Effect.promise(() => config.ensureService!(opts.service)); +} +``` + +In `createStack.ts`, when `resolved.lazyServices`, build the memo over `startService` + `waitReady` and pass it into the ApiProxy layer's `ProxyConfig`. + +- [ ] **Step 4: Run to verify pass + no regressions** + +Run: `cd packages/stack && pnpm vitest run src/lazyServices.unit.test.ts && pnpm vitest run --exclude '**/*.e2e.test.ts'` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/stack/src +git commit -m "feat(stack): lazy per-service start behind the API proxy" +``` + +--- + +### Task 7: Fleet manifest types + template cache key + +**Files:** +- Create: `packages/fleet/src/PodManifest.ts` +- Test: `packages/fleet/src/PodManifest.unit.test.ts` + +**Interfaces:** +- Consumes: `VersionManifest`, `ServiceName` types from `@supabase/stack`. +- Produces: + - `interface PodManifest { readonly id: string; readonly versions: Partial; readonly services: Partial>; readonly flags: { readonly supautils: boolean }; readonly ports: { readonly dbPort: number; readonly apiPort: number }; readonly createdAt: string; }` + - `templateKey(versions: Partial): string` — stable sha256-based key: same versions in any order → same key; different versions → different key. + - `baseTemplateKey(postgresVersion: string): string` → `"pg-" + postgresVersion`. + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/PodManifest.unit.test.ts +import { describe, expect, it } from "vitest"; +import { baseTemplateKey, templateKey } from "./PodManifest.ts"; + +describe("templateKey", () => { + it("is stable across key order", () => { + expect(templateKey({ postgres: "17.6.1.143", auth: "2.192.0" })).toBe( + templateKey({ auth: "2.192.0", postgres: "17.6.1.143" }), + ); + }); + it("changes when any version changes", () => { + expect(templateKey({ postgres: "17.6.1.143", auth: "2.192.0" })).not.toBe( + templateKey({ postgres: "17.6.1.143", auth: "2.192.1" }), + ); + }); + it("base key is human-readable", () => { + expect(baseTemplateKey("17.6.1.143")).toBe("pg-17.6.1.143"); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/fleet && pnpm vitest run src/PodManifest.unit.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```typescript +// src/PodManifest.ts +import { createHash } from "node:crypto"; +import type { ServiceName, VersionManifest } from "@supabase/stack"; + +export interface PodManifest { + readonly id: string; + readonly versions: Partial; + readonly services: Partial>; + readonly flags: { readonly supautils: boolean }; + readonly ports: { readonly dbPort: number; readonly apiPort: number }; + readonly createdAt: string; +} + +export const baseTemplateKey = (postgresVersion: string): string => + `pg-${postgresVersion}`; + +export const templateKey = (versions: Partial): string => { + const canonical = JSON.stringify( + Object.fromEntries(Object.entries(versions).sort(([a], [b]) => a.localeCompare(b))), + ); + return `tuple-${createHash("sha256").update(canonical).digest("hex").slice(0, 16)}`; +}; +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/fleet && pnpm vitest run src/PodManifest.unit.test.ts` +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add packages/fleet/src +git commit -m "feat(fleet): pod manifest types and template cache keys" +``` + +--- + +### Task 8: Copy-on-write directory clone + +**Files:** +- Create: `packages/fleet/src/cowClone.ts` +- Test: `packages/fleet/src/cowClone.integration.test.ts` (touches the filesystem) + +**Interfaces:** +- Produces: `cloneDir(src: string, dest: string): Promise` — APFS `cp -Rc` on darwin, `cp -R --reflink=auto` on linux, recursive copy fallback; `dest` must not exist; preserves file modes (postgres requires `0700` on PGDATA). + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/cowClone.integration.test.ts +import { mkdtemp, mkdir, readFile, stat, writeFile, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { cloneDir } from "./cowClone.ts"; + +describe("cloneDir", () => { + it("clones a tree with content and modes; clones diverge", async () => { + const root = await mkdtemp(join(tmpdir(), "cow-test-")); + const src = join(root, "src"); + await mkdir(join(src, "sub"), { recursive: true }); + await writeFile(join(src, "sub", "a.txt"), "hello"); + await chmod(src, 0o700); + + const dest = join(root, "dest"); + await cloneDir(src, dest); + + expect(await readFile(join(dest, "sub", "a.txt"), "utf8")).toBe("hello"); + expect(((await stat(dest)).mode & 0o777)).toBe(0o700); + + await writeFile(join(dest, "sub", "a.txt"), "changed"); + expect(await readFile(join(src, "sub", "a.txt"), "utf8")).toBe("hello"); + }); + + it("refuses to clone onto an existing destination", async () => { + const root = await mkdtemp(join(tmpdir(), "cow-test-")); + const src = join(root, "src"); + await mkdir(src); + const dest = join(root, "dest"); + await mkdir(dest); + await expect(cloneDir(src, dest)).rejects.toThrow(/exists/); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/fleet && pnpm vitest run src/cowClone.integration.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```typescript +// src/cowClone.ts +import { spawn } from "node:child_process"; +import { cp, stat } from "node:fs/promises"; + +const run = (cmd: string, args: string[]): Promise => + new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: "ignore" }); + child.on("error", reject); + child.on("exit", (code) => resolve(code ?? 1)); + }); + +/** Copy-on-write directory clone: APFS clonefile on macOS, reflink on Linux, plain copy fallback. */ +export async function cloneDir(src: string, dest: string): Promise { + const exists = await stat(dest).then(() => true, () => false); + if (exists) throw new Error(`cloneDir: destination already exists: ${dest}`); + + if (process.platform === "darwin") { + if ((await run("cp", ["-Rc", src, dest])) === 0) return; + } else if (process.platform === "linux") { + if ((await run("cp", ["-R", "--reflink=auto", src, dest])) === 0) return; + } + // Fallback (non-CoW filesystems, other platforms): plain recursive copy. + await cp(src, dest, { recursive: true, force: false, errorOnExist: true }); +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/fleet && pnpm vitest run src/cowClone.integration.test.ts` +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add packages/fleet/src +git commit -m "feat(fleet): copy-on-write directory clone (clonefile/reflink/copy)" +``` + +--- + +### Task 9: Persistent PortRegistry + +**Files:** +- Create: `packages/fleet/src/PortRegistry.ts` +- Test: `packages/fleet/src/PortRegistry.unit.test.ts` + +**Interfaces:** +- Produces a class (plain TS, JSON persistence, no Effect needed at this layer): + - `PortRegistry.load(stateFile: string): Promise` + - `allocate(podId: string): Promise<{ dbPort: number; apiPort: number }>` — deterministic scan from a base (default 55000), skipping assigned ports; persists after each allocation (atomic write: tmp file + rename) + - `release(podId: string): Promise` + - `get(podId: string): { dbPort: number; apiPort: number } | undefined` +- This replaces `readReservedPorts()`'s cross-stack filesystem scan for fleet-managed pods: the registry is the single owner of the port space. + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/PortRegistry.unit.test.ts +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PortRegistry } from "./PortRegistry.ts"; + +describe("PortRegistry", () => { + it("allocates unique port pairs and persists them", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const reg = await PortRegistry.load(file); + const a = await reg.allocate("pod-a"); + const b = await reg.allocate("pod-b"); + expect(new Set([a.dbPort, a.apiPort, b.dbPort, b.apiPort]).size).toBe(4); + + const reloaded = await PortRegistry.load(file); + expect(reloaded.get("pod-a")).toEqual(a); + expect(reloaded.get("pod-b")).toEqual(b); + }); + + it("is idempotent per pod and reuses released ports", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const reg = await PortRegistry.load(file); + const a1 = await reg.allocate("pod-a"); + const a2 = await reg.allocate("pod-a"); + expect(a2).toEqual(a1); + await reg.release("pod-a"); + expect(reg.get("pod-a")).toBeUndefined(); + const c = await reg.allocate("pod-c"); + expect(c.dbPort).toBe(a1.dbPort); // freed ports are reusable + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/fleet && pnpm vitest run src/PortRegistry.unit.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```typescript +// src/PortRegistry.ts +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +export interface PodPorts { + readonly dbPort: number; + readonly apiPort: number; +} + +interface PortState { + readonly basePort: number; + readonly pods: Record; +} + +const DEFAULT_BASE_PORT = 55000; + +export class PortRegistry { + private constructor( + private readonly stateFile: string, + private state: PortState, + ) {} + + static async load(stateFile: string): Promise { + const raw = await readFile(stateFile, "utf8").catch(() => undefined); + const state: PortState = + raw !== undefined + ? (JSON.parse(raw) as PortState) + : { basePort: DEFAULT_BASE_PORT, pods: {} }; + return new PortRegistry(stateFile, state); + } + + get(podId: string): PodPorts | undefined { + return this.state.pods[podId]; + } + + async allocate(podId: string): Promise { + const existing = this.state.pods[podId]; + if (existing) return existing; + const used = new Set( + Object.values(this.state.pods).flatMap((p) => [p.dbPort, p.apiPort]), + ); + let candidate = this.state.basePort; + const next = (): number => { + while (used.has(candidate)) candidate += 1; + used.add(candidate); + return candidate; + }; + const ports: PodPorts = { dbPort: next(), apiPort: next() }; + this.state = { ...this.state, pods: { ...this.state.pods, [podId]: ports } }; + await this.persist(); + return ports; + } + + async release(podId: string): Promise { + const { [podId]: _, ...rest } = this.state.pods; + this.state = { ...this.state, pods: rest }; + await this.persist(); + } + + private async persist(): Promise { + await mkdir(dirname(this.stateFile), { recursive: true }); + const tmp = `${this.stateFile}.tmp`; + await writeFile(tmp, JSON.stringify(this.state, null, 2)); + await rename(tmp, this.stateFile); + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/fleet && pnpm vitest run src/PortRegistry.unit.test.ts` +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add packages/fleet/src +git commit -m "feat(fleet): persistent port registry" +``` + +--- + +### Task 10: TemplateStore — base and warm templates built via the stack itself + +**Files:** +- Create: `packages/fleet/src/TemplateStore.ts` +- Test: `packages/fleet/src/TemplateStore.integration.test.ts` (spawns real postgres — needs the binary cache; skip in CI environments without it via `describe.skipIf(!process.env.FLEET_PG_TESTS)`) + +**Interfaces:** +- Consumes: `createStack` from `@supabase/stack` (bun entry), `installMicroProfile` from `@supabase/stack` (export it from stack's index in this task), `cloneDir` (Task 8), `baseTemplateKey`/`templateKey` (Task 7). +- Produces: + - `class TemplateStore` with: + - `constructor(root: string)` — `root` = templates dir (e.g. `~/.supabase/templates`) + - `ensureBaseTemplate(postgresVersion: string): Promise` — returns template data-dir path. If absent: run a one-shot stack (`postgres` only, non-provisioned, so `postgres-init` applies baseline migrations), stop it, run `installMicroProfile(dataDir)`, move into `root/pg-/data`, write `template.json` marker. + - `ensureWarmTemplate(versions: Partial, enabledServices: ReadonlyArray): Promise` — clone base, boot the listed services once (services self-migrate), stop, freeze under `root//data`. Falls back to base when `enabledServices` is empty. + - `has(key: string): Promise` +- Build must be concurrency-safe on one host: take a lockfile (`root/.lock` created with `wx` flag; poll-wait if held; stale after 10min). + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/TemplateStore.integration.test.ts +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { TemplateStore } from "./TemplateStore.ts"; + +// Requires postgres binaries in the local cache; opt-in via env. +describe.skipIf(!process.env.FLEET_PG_TESTS)("TemplateStore", () => { + it("builds a base template once and reuses it", async () => { + const root = await mkdtemp(join(tmpdir(), "templates-")); + const store = new TemplateStore(root); + const first = await store.ensureBaseTemplate("17.6.1.143"); + expect(first).toContain("pg-17.6.1.143"); + // PGDATA got the micro profile + const { readFile } = await import("node:fs/promises"); + const conf = await readFile(join(first, "postgresql.conf"), "utf8"); + expect(conf).toContain("include_if_exists = 'micro.conf'"); + + const started = Date.now(); + const second = await store.ensureBaseTemplate("17.6.1.143"); + expect(second).toBe(first); + expect(Date.now() - started).toBeLessThan(1000); // cache hit, no rebuild + }, 300_000); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/fleet && FLEET_PG_TESTS=1 pnpm vitest run src/TemplateStore.integration.test.ts` +Expected: FAIL — module not found. (Without the env var it must SKIP, verify that too.) + +- [ ] **Step 3: Implement** + +```typescript +// src/TemplateStore.ts +import { mkdir, open, rename, rm, stat, writeFile, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { createStack, installMicroProfile } from "@supabase/stack/bun"; +import type { ServiceName, VersionManifest } from "@supabase/stack"; +import { cloneDir } from "./cowClone.ts"; +import { baseTemplateKey, templateKey } from "./PodManifest.ts"; + +const LOCK_STALE_MS = 10 * 60 * 1000; + +export class TemplateStore { + constructor(private readonly root: string) {} + + private dataDir(key: string): string { + return join(this.root, key, "data"); + } + + async has(key: string): Promise { + return stat(join(this.root, key, "template.json")).then(() => true, () => false); + } + + async ensureBaseTemplate(postgresVersion: string): Promise { + const key = baseTemplateKey(postgresVersion); + if (await this.has(key)) return this.dataDir(key); + return this.withLock(key, async () => { + if (await this.has(key)) return this.dataDir(key); + const buildDir = join(this.root, `${key}.build`); + await rm(buildDir, { recursive: true, force: true }); + await mkdir(buildDir, { recursive: true }); + // One-shot stack: postgres only, non-provisioned → postgres-init applies + // roles/schemas/baseline migrations exactly as today. + const stack = await createStack({ + postgres: { version: postgresVersion, dataDir: join(buildDir, "data") }, + postgrest: false, auth: false, edgeRuntime: false, realtime: false, + storage: false, imgproxy: false, mailpit: false, pgmeta: false, + studio: false, analytics: false, vector: false, pooler: false, + functions: false, + }); + await stack.start(); + await stack.ready(); + await stack.dispose(); // clean shutdown + await installMicroProfile(join(buildDir, "data")); + await mkdir(join(this.root, key), { recursive: true }); + await rename(join(buildDir, "data"), this.dataDir(key)); + await writeFile( + join(this.root, key, "template.json"), + JSON.stringify({ key, postgresVersion, builtAt: new Date().toISOString() }), + ); + await rm(buildDir, { recursive: true, force: true }); + return this.dataDir(key); + }); + } + + async ensureWarmTemplate( + versions: Partial, + enabledServices: ReadonlyArray, + ): Promise { + const pgVersion = versions.postgres; + if (pgVersion === undefined) throw new Error("versions.postgres is required"); + const base = await this.ensureBaseTemplate(pgVersion); + if (enabledServices.length === 0) return base; + const key = templateKey(versions); + if (await this.has(key)) return this.dataDir(key); + return this.withLock(key, async () => { + if (await this.has(key)) return this.dataDir(key); + const buildDir = join(this.root, `${key}.build`); + await rm(buildDir, { recursive: true, force: true }); + await mkdir(buildDir, { recursive: true }); + await cloneDir(base, join(buildDir, "data")); + const stack = await createStack({ + postgres: { version: pgVersion, dataDir: join(buildDir, "data"), provisioned: true, profile: "micro" }, + // enable exactly the listed services so each self-migrates once: + postgrest: enabledServices.includes("postgrest") ? {} : false, + auth: enabledServices.includes("auth") ? {} : false, + realtime: enabledServices.includes("realtime") ? {} : false, + edgeRuntime: enabledServices.includes("edge-runtime") ? {} : false, + storage: false, imgproxy: false, mailpit: false, pgmeta: false, + studio: false, analytics: false, vector: false, pooler: false, + functions: false, + }); + await stack.start(); + await stack.ready(); + await stack.dispose(); + await mkdir(join(this.root, key), { recursive: true }); + await rename(join(buildDir, "data"), this.dataDir(key)); + await writeFile( + join(this.root, key, "template.json"), + JSON.stringify({ key, versions, enabledServices, builtAt: new Date().toISOString() }), + ); + await rm(buildDir, { recursive: true, force: true }); + return this.dataDir(key); + }); + } + + private async withLock(key: string, body: () => Promise): Promise { + const lockPath = join(this.root, `${key}.lock`); + await mkdir(this.root, { recursive: true }); + for (;;) { + try { + const handle = await open(lockPath, "wx"); + await handle.close(); + break; + } catch { + const s = await stat(lockPath).catch(() => undefined); + if (s && Date.now() - s.mtimeMs > LOCK_STALE_MS) { + await unlink(lockPath).catch(() => {}); + continue; + } + await new Promise((r) => setTimeout(r, 250)); + } + } + try { + return await body(); + } finally { + await unlink(lockPath).catch(() => {}); + } + } +} +``` + +Also in `packages/stack/src/index.ts`, export the pgconf helpers so fleet can use them: + +```typescript +export { installMicroProfile, readPreloadLibraries, writePreloadLibraries } from "./pgconf.ts"; +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/fleet && FLEET_PG_TESTS=1 pnpm vitest run src/TemplateStore.integration.test.ts` +Expected: PASS (first run downloads/boots postgres — allow minutes). Then without env: `pnpm vitest run src/TemplateStore.integration.test.ts` → skipped. + +- [ ] **Step 5: Commit** + +```bash +git add packages/fleet/src packages/stack/src/index.ts +git commit -m "feat(fleet): template store with base and warm templates" +``` + +--- + +### Task 11: PodRegistry + Provisioner (create / reset / fork / destroy) + +**Files:** +- Create: `packages/fleet/src/PodRegistry.ts` +- Create: `packages/fleet/src/Provisioner.ts` +- Test: `packages/fleet/src/Provisioner.integration.test.ts` + +**Interfaces:** +- Consumes: `PodManifest`/`templateKey` (Task 7), `cloneDir` (Task 8), `PortRegistry` (Task 9), `TemplateStore` (Task 10). +- Produces: + - `class PodRegistry { constructor(podsRoot: string); read(id): Promise; write(manifest): Promise; list(): Promise; remove(id): Promise; podDir(id): string; dataDir(id): string; }` — manifest at `podsRoot//pod.json`. + - `class Provisioner { constructor(opts: { templates: TemplateStore; pods: PodRegistry; ports: PortRegistry }); create(opts: { id: string; versions: Partial; services?: Partial>; flags?: { supautils?: boolean }; warm?: boolean }): Promise; reset(id: string): Promise; fork(sourceId: string, newId: string): Promise; destroy(id: string): Promise; }` + - `create`: allocate ports → ensure template (warm if `warm: true`, else base) → `cloneDir(template, dataDir)` → write manifest. Rejects on duplicate id. + - `reset`: delete `data`, re-clone from the same template. `fork`: requires source **suspended** (caller's responsibility at this layer; Fleet enforces in Task 14) → clone source's data dir + fresh ports + new manifest. `destroy`: remove pod dir, release ports. + +- [ ] **Step 1: Write the failing test** (uses base template; postgres-dependent → gate like Task 10) + +```typescript +// src/Provisioner.integration.test.ts +import { mkdtemp, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PodRegistry } from "./PodRegistry.ts"; +import { PortRegistry } from "./PortRegistry.ts"; +import { Provisioner } from "./Provisioner.ts"; +import { TemplateStore } from "./TemplateStore.ts"; + +const PG_VERSION = "17.6.1.143"; + +describe.skipIf(!process.env.FLEET_PG_TESTS)("Provisioner", () => { + async function makeProvisioner() { + const root = await mkdtemp(join(tmpdir(), "fleet-")); + const templates = new TemplateStore(join(root, "templates")); + const pods = new PodRegistry(join(root, "pods")); + const ports = await PortRegistry.load(join(root, "fleet-state.json")); + return { p: new Provisioner({ templates, pods, ports }), pods }; + } + + it("creates, forks, resets, destroys", async () => { + const { p, pods } = await makeProvisioner(); + const a = await p.create({ id: "a", versions: { postgres: PG_VERSION } }); + expect(a.ports.dbPort).toBeGreaterThan(0); + expect(await stat(join(pods.dataDir("a"), "PG_VERSION")).then(() => true)).toBe(true); + + // fork: divergence + await writeFile(join(pods.dataDir("a"), "marker.txt"), "from-a"); + const b = await p.fork("a", "b"); + expect(b.ports.dbPort).not.toBe(a.ports.dbPort); + await writeFile(join(pods.dataDir("b"), "marker.txt"), "from-b"); + expect(await Bun.file(join(pods.dataDir("a"), "marker.txt")).text()).toBe("from-a"); + + // reset: marker disappears (re-cloned from template) + await p.reset("a"); + expect(await stat(join(pods.dataDir("a"), "marker.txt")).then(() => true, () => false)).toBe(false); + + await p.destroy("a"); + await p.destroy("b"); + expect(await pods.list()).toEqual([]); + }, 300_000); + + it("rejects duplicate ids", async () => { + const { p } = await makeProvisioner(); + await p.create({ id: "dup", versions: { postgres: PG_VERSION } }); + await expect(p.create({ id: "dup", versions: { postgres: PG_VERSION } })).rejects.toThrow(/exists/); + }, 300_000); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/fleet && FLEET_PG_TESTS=1 pnpm vitest run src/Provisioner.integration.test.ts` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Implement** + +```typescript +// src/PodRegistry.ts +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { PodManifest } from "./PodManifest.ts"; + +export class PodRegistry { + constructor(private readonly podsRoot: string) {} + + podDir(id: string): string { + return join(this.podsRoot, id); + } + dataDir(id: string): string { + return join(this.podsRoot, id, "data"); + } + + async read(id: string): Promise { + const raw = await readFile(join(this.podDir(id), "pod.json"), "utf8").catch(() => undefined); + return raw === undefined ? undefined : (JSON.parse(raw) as PodManifest); + } + + async write(manifest: PodManifest): Promise { + await mkdir(this.podDir(manifest.id), { recursive: true }); + await writeFile(join(this.podDir(manifest.id), "pod.json"), JSON.stringify(manifest, null, 2)); + } + + async list(): Promise { + const entries = await readdir(this.podsRoot).catch(() => [] as string[]); + const manifests = await Promise.all(entries.map((id) => this.read(id))); + return manifests.filter((m): m is PodManifest => m !== undefined); + } + + async remove(id: string): Promise { + await rm(this.podDir(id), { recursive: true, force: true }); + } +} +``` + +```typescript +// src/Provisioner.ts +import { rm } from "node:fs/promises"; +import type { ServiceName, VersionManifest } from "@supabase/stack"; +import { cloneDir } from "./cowClone.ts"; +import type { PodManifest } from "./PodManifest.ts"; +import type { PodRegistry } from "./PodRegistry.ts"; +import type { PortRegistry } from "./PortRegistry.ts"; +import type { TemplateStore } from "./TemplateStore.ts"; + +export interface CreatePodOptions { + readonly id: string; + readonly versions: Partial; + readonly services?: Partial>; + readonly flags?: { readonly supautils?: boolean }; + /** Build/use a warm template (services pre-migrated). Default: base template. */ + readonly warm?: boolean; +} + +export class Provisioner { + constructor( + private readonly deps: { + readonly templates: TemplateStore; + readonly pods: PodRegistry; + readonly ports: PortRegistry; + }, + ) {} + + async create(opts: CreatePodOptions): Promise { + const { templates, pods, ports } = this.deps; + if ((await pods.read(opts.id)) !== undefined) { + throw new Error(`pod already exists: ${opts.id}`); + } + const pgVersion = opts.versions.postgres; + if (pgVersion === undefined) throw new Error("versions.postgres is required"); + const enabled = Object.entries(opts.services ?? {}) + .filter(([, on]) => on === true) + .map(([name]) => name as ServiceName); + const template = + opts.warm === true + ? await templates.ensureWarmTemplate(opts.versions, enabled) + : await templates.ensureBaseTemplate(pgVersion); + const allocated = await ports.allocate(opts.id); + await cloneDir(template, pods.dataDir(opts.id)); + const manifest: PodManifest = { + id: opts.id, + versions: opts.versions, + services: opts.services ?? {}, + flags: { supautils: opts.flags?.supautils ?? false }, + ports: allocated, + createdAt: new Date().toISOString(), + }; + await pods.write(manifest); + return manifest; + } + + async reset(id: string): Promise { + const { templates, pods } = this.deps; + const manifest = await pods.read(id); + if (manifest === undefined) throw new Error(`unknown pod: ${id}`); + const pgVersion = manifest.versions.postgres; + if (pgVersion === undefined) throw new Error(`pod ${id} has no postgres version`); + const template = await templates.ensureBaseTemplate(pgVersion); + await rm(pods.dataDir(id), { recursive: true, force: true }); + await cloneDir(template, pods.dataDir(id)); + } + + /** Caller must ensure the source pod is stopped/suspended first. */ + async fork(sourceId: string, newId: string): Promise { + const { pods, ports } = this.deps; + const source = await pods.read(sourceId); + if (source === undefined) throw new Error(`unknown pod: ${sourceId}`); + if ((await pods.read(newId)) !== undefined) { + throw new Error(`pod already exists: ${newId}`); + } + const allocated = await ports.allocate(newId); + await cloneDir(pods.dataDir(sourceId), pods.dataDir(newId)); + const manifest: PodManifest = { + ...source, + id: newId, + ports: allocated, + createdAt: new Date().toISOString(), + }; + await pods.write(manifest); + return manifest; + } + + async destroy(id: string): Promise { + const { pods, ports } = this.deps; + await pods.remove(id); + await ports.release(id); + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/fleet && FLEET_PG_TESTS=1 pnpm vitest run src/Provisioner.integration.test.ts` +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add packages/fleet/src +git commit -m "feat(fleet): pod registry and provisioner (create/reset/fork/destroy)" +``` + +--- + +### Task 12: EdgeProxy — TCP wake-proxy with traffic events + +**Files:** +- Create: `packages/fleet/src/EdgeProxy.ts` +- Test: `packages/fleet/src/EdgeProxy.unit.test.ts` (uses plain TCP echo servers, no postgres) + +**Interfaces:** +- Produces: + +```typescript +export interface EdgeProxyEvents { + /** Fired on connect/disconnect/bytes; IdleMonitor consumes these. */ + onActivity: (podId: string, event: "connect" | "data" | "disconnect", openConnections: number) => void; +} +export interface PodUpstream { readonly host: string; readonly port: number } +export class EdgeProxy { + constructor(events?: Partial); + /** Bind listenPort now and forever; `wake` is awaited per-connection to get the upstream. */ + register(podId: string, listenPort: number, wake: () => Promise): Promise; + unregister(podId: string): Promise; + openConnections(podId: string): number; + close(): Promise; +} +``` + +- Semantics: listener accepts immediately; each accepted socket pauses, awaits `wake()`, then splices to the upstream with `socket.pipe(upstream)` both ways. `wake` failures destroy the client socket. Activity events fire on every data chunk in either direction. + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/EdgeProxy.unit.test.ts +import { createServer, connect, type AddressInfo, type Server } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { EdgeProxy } from "./EdgeProxy.ts"; + +function echoServer(): Promise<{ server: Server; port: number }> { + return new Promise((resolve) => { + const server = createServer((sock) => sock.pipe(sock)); + server.listen(0, "127.0.0.1", () => + resolve({ server, port: (server.address() as AddressInfo).port }), + ); + }); +} + +function freePort(): Promise { + return new Promise((resolve) => { + const s = createServer(); + s.listen(0, "127.0.0.1", () => { + const port = (s.address() as AddressInfo).port; + s.close(() => resolve(port)); + }); + }); +} + +describe("EdgeProxy", () => { + const proxies: EdgeProxy[] = []; + afterEach(async () => { + for (const p of proxies.splice(0)) await p.close(); + }); + + it("wakes on first connection and splices bytes both ways", async () => { + const { server, port: upstreamPort } = await echoServer(); + let wakes = 0; + const proxy = new EdgeProxy(); + proxies.push(proxy); + const listenPort = await freePort(); + await proxy.register("pod-a", listenPort, async () => { + wakes += 1; + return { host: "127.0.0.1", port: upstreamPort }; + }); + + const reply = await new Promise((resolve, reject) => { + const sock = connect(listenPort, "127.0.0.1", () => sock.write("ping")); + sock.on("data", (d) => { resolve(d.toString()); sock.end(); }); + sock.on("error", reject); + }); + expect(reply).toBe("ping"); + expect(wakes).toBe(1); + server.close(); + }); + + it("tracks open connections and reports activity", async () => { + const { server, port: upstreamPort } = await echoServer(); + const events: string[] = []; + const proxy = new EdgeProxy({ + onActivity: (id, ev) => events.push(`${id}:${ev}`), + }); + proxies.push(proxy); + const listenPort = await freePort(); + await proxy.register("pod-b", listenPort, async () => ({ host: "127.0.0.1", port: upstreamPort })); + + await new Promise((resolve) => { + const sock = connect(listenPort, "127.0.0.1", () => sock.write("x")); + sock.on("data", () => sock.end()); + sock.on("close", () => resolve()); + }); + await new Promise((r) => setTimeout(r, 50)); + expect(events).toContain("pod-b:connect"); + expect(events).toContain("pod-b:data"); + expect(events).toContain("pod-b:disconnect"); + expect(proxy.openConnections("pod-b")).toBe(0); + server.close(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/fleet && pnpm vitest run src/EdgeProxy.unit.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```typescript +// src/EdgeProxy.ts +import { connect, createServer, type Server, type Socket } from "node:net"; + +export interface PodUpstream { + readonly host: string; + readonly port: number; +} + +export interface EdgeProxyEvents { + onActivity: ( + podId: string, + event: "connect" | "data" | "disconnect", + openConnections: number, + ) => void; +} + +interface Registration { + readonly server: Server; + readonly sockets: Set; +} + +export class EdgeProxy { + private readonly registrations = new Map(); + + constructor(private readonly events: Partial = {}) {} + + openConnections(podId: string): number { + return this.registrations.get(podId)?.sockets.size ?? 0; + } + + register( + podId: string, + listenPort: number, + wake: () => Promise, + ): Promise { + const sockets = new Set(); + const emit = (event: "connect" | "data" | "disconnect") => + this.events.onActivity?.(podId, event, sockets.size); + + const server = createServer((client) => { + sockets.add(client); + client.pause(); + emit("connect"); + const cleanup = () => { + if (sockets.delete(client)) emit("disconnect"); + }; + client.on("close", cleanup); + client.on("error", cleanup); + wake().then( + (upstream) => { + const backend = connect(upstream.port, upstream.host); + backend.on("error", () => client.destroy()); + client.on("close", () => backend.destroy()); + backend.on("close", () => client.destroy()); + client.on("data", () => emit("data")); + backend.on("data", () => emit("data")); + backend.on("connect", () => { + client.pipe(backend); + backend.pipe(client); + client.resume(); + }); + }, + () => client.destroy(), + ); + }); + + this.registrations.set(podId, { server, sockets }); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(listenPort, "127.0.0.1", () => resolve()); + }); + } + + async unregister(podId: string): Promise { + const reg = this.registrations.get(podId); + if (!reg) return; + this.registrations.delete(podId); + for (const sock of reg.sockets) sock.destroy(); + await new Promise((resolve) => reg.server.close(() => resolve())); + } + + async close(): Promise { + await Promise.all([...this.registrations.keys()].map((id) => this.unregister(id))); + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/fleet && pnpm vitest run src/EdgeProxy.unit.test.ts` +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add packages/fleet/src +git commit -m "feat(fleet): TCP wake-proxy edge with activity events" +``` + +--- + +### Task 13: IdleMonitor + +**Files:** +- Create: `packages/fleet/src/IdleMonitor.ts` +- Test: `packages/fleet/src/IdleMonitor.unit.test.ts` (fake timers) + +**Interfaces:** +- Consumes: activity events shape from Task 12. +- Produces: + +```typescript +export class IdleMonitor { + constructor(opts: { + idleMs: number; + onIdle: (podId: string) => void; + now?: () => number; // injectable clock for tests + schedule?: typeof setTimeout; // injectable timer for tests + }); + /** Wire to EdgeProxy events: any activity resets the timer; open connections hold it. */ + recordActivity(podId: string, openConnections: number): void; + /** Start tracking a pod (e.g. on wake). */ + track(podId: string): void; + /** Stop tracking (on suspend/destroy). */ + untrack(podId: string): void; +} +``` + +- Semantics (spec: "no open external connections AND no bytes for T"): while `openConnections > 0` a pod never goes idle; when the last connection closes, the countdown starts from that moment; any activity resets it; `onIdle` fires at most once per warm period (re-armed by the next `track`). + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/IdleMonitor.unit.test.ts +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IdleMonitor } from "./IdleMonitor.ts"; + +describe("IdleMonitor", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("fires onIdle after idleMs with no connections and no activity", () => { + const idled: string[] = []; + const mon = new IdleMonitor({ idleMs: 1000, onIdle: (id) => idled.push(id) }); + mon.track("a"); + mon.recordActivity("a", 0); + vi.advanceTimersByTime(999); + expect(idled).toEqual([]); + vi.advanceTimersByTime(2); + expect(idled).toEqual(["a"]); + }); + + it("open connections hold the pod warm indefinitely", () => { + const idled: string[] = []; + const mon = new IdleMonitor({ idleMs: 1000, onIdle: (id) => idled.push(id) }); + mon.track("a"); + mon.recordActivity("a", 1); // one open connection + vi.advanceTimersByTime(10_000); + expect(idled).toEqual([]); + mon.recordActivity("a", 0); // last connection closed + vi.advanceTimersByTime(1001); + expect(idled).toEqual(["a"]); + }); + + it("activity resets the countdown; untrack cancels", () => { + const idled: string[] = []; + const mon = new IdleMonitor({ idleMs: 1000, onIdle: (id) => idled.push(id) }); + mon.track("a"); + mon.recordActivity("a", 0); + vi.advanceTimersByTime(900); + mon.recordActivity("a", 0); // reset + vi.advanceTimersByTime(900); + expect(idled).toEqual([]); + mon.untrack("a"); + vi.advanceTimersByTime(5000); + expect(idled).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/fleet && pnpm vitest run src/IdleMonitor.unit.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```typescript +// src/IdleMonitor.ts +interface Tracked { + timer: ReturnType | undefined; + openConnections: number; +} + +export class IdleMonitor { + private readonly tracked = new Map(); + + constructor( + private readonly opts: { + readonly idleMs: number; + readonly onIdle: (podId: string) => void; + }, + ) {} + + track(podId: string): void { + if (!this.tracked.has(podId)) { + this.tracked.set(podId, { timer: undefined, openConnections: 0 }); + this.arm(podId); + } + } + + untrack(podId: string): void { + const entry = this.tracked.get(podId); + if (entry?.timer) clearTimeout(entry.timer); + this.tracked.delete(podId); + } + + recordActivity(podId: string, openConnections: number): void { + const entry = this.tracked.get(podId); + if (!entry) return; + entry.openConnections = openConnections; + this.arm(podId); + } + + private arm(podId: string): void { + const entry = this.tracked.get(podId); + if (!entry) return; + if (entry.timer) clearTimeout(entry.timer); + entry.timer = undefined; + if (entry.openConnections > 0) return; // held warm by open connections + entry.timer = setTimeout(() => { + this.tracked.delete(podId); + this.opts.onIdle(podId); + }, this.opts.idleMs); + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/fleet && pnpm vitest run src/IdleMonitor.unit.test.ts` +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add packages/fleet/src +git commit -m "feat(fleet): idle monitor with connection-aware countdown" +``` + +--- + +### Task 14: Fleet facade — wake/suspend lifecycle + startup reconciliation + +**Files:** +- Create: `packages/fleet/src/Fleet.ts` +- Modify: `packages/fleet/src/index.ts` (public exports) +- Test: `packages/fleet/src/Fleet.integration.test.ts` + +**Interfaces:** +- Consumes: everything from Tasks 7–13, `createStack` + `StackHandle` from `@supabase/stack/bun`. +- Produces the package's public API: + +```typescript +export interface FleetOptions { + readonly root?: string; // default: join(homedir(), ".supabase") + readonly idleMs?: number; // default: 5 * 60_000 +} +export interface PodStatus { + readonly manifest: PodManifest; + readonly state: "suspended" | "waking" | "warm" | "suspending"; + readonly dbUrl: string; // through the edge proxy port — stable across suspend cycles +} +export interface FleetHandle extends AsyncDisposable { + createPod(opts: CreatePodOptions): Promise; + destroyPod(id: string): Promise; + resetPod(id: string): Promise; + forkPod(sourceId: string, newId: string): Promise; + wake(id: string): Promise; + suspend(id: string): Promise; + ensureExtensionPreload(id: string, extension: string): Promise; + listPods(): Promise>; + dispose(): Promise; +} +export function createFleet(opts?: FleetOptions): Promise; +``` + +- Key behaviors: + - `createPod` registers the pod's `dbPort` on the EdgeProxy immediately (suspended pods answer the port; first connection wakes them). The pod's internal postgres listens on an ephemeral port chosen at wake time (`apiPort`-style allocation is internal); the **external** `dbPort` never changes. + - Wake path: `wake(id)` (or first proxied connection) → `createStack({ postgres: { dataDir, provisioned: true, profile: "micro", port: }, lazyServices: true, ...services-from-manifest })` → `start()` → `serviceReady("postgres")` → IdleMonitor `track(id)`. Memoize concurrent wakes (reuse `makeEnsureServiceMemo` pattern with per-pod keys). + - Suspend path: IdleMonitor `onIdle` → `suspend(id)` → `stack.dispose()` → drop the StackHandle → state `suspended`. EdgeProxy registration stays. + - `forkPod`: `suspend(source)` first if warm, then `Provisioner.fork`. + - `ensureExtensionPreload`: if warm → delegate to `StackHandle.ensureExtensionPreload`; if suspended → edit pod.conf directly via `writePreloadLibraries(dataDir, ...)` (no restart needed — next wake picks it up). + - **Startup reconciliation:** on `createFleet`, scan `podsRoot/*/run.pid`; any live process groups from a previous daemon are terminated (SIGTERM, then SIGKILL after 5s) and their pods marked suspended. Phase 1 explicitly kills-then-wakes rather than adopting (documented deviation from the spec's adoption goal; acceptable because data is disposable and wake is ~fast — revisit in a later phase). Write `run.pid` on wake, remove on suspend. + - `dispose()` suspends all warm pods and closes the EdgeProxy. + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/Fleet.integration.test.ts +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createFleet } from "./Fleet.ts"; + +const PG_VERSION = "17.6.1.143"; + +async function query(dbUrl: string, sql: string): Promise { + // Minimal client via Bun's built-in postgres support. If this suite runs under + // Node-based vitest instead of Bun, swap for the `postgres` npm package (dev dep). + const { SQL } = await import("bun"); + const db = new SQL(dbUrl); + const rows = await db.unsafe(sql); + await db.close(); + return JSON.stringify(rows); +} + +describe.skipIf(!process.env.FLEET_PG_TESTS)("Fleet", () => { + it("wake-on-connect, suspend-on-idle, fork", async () => { + const root = await mkdtemp(join(tmpdir(), "fleet-e2e-")); + await using fleet = await createFleet({ root, idleMs: 2000 }); + + const a = await fleet.createPod({ id: "a", versions: { postgres: PG_VERSION } }); + expect(a.state).toBe("suspended"); + + // First connection wakes the pod transparently. + await query(a.dbUrl, "create table t(x int); insert into t values (1)"); + const warm = (await fleet.listPods()).find((p) => p.manifest.id === "a"); + expect(warm?.state).toBe("warm"); + + // Idle out (no connections) → suspended. + await new Promise((r) => setTimeout(r, 4000)); + const idle = (await fleet.listPods()).find((p) => p.manifest.id === "a"); + expect(idle?.state).toBe("suspended"); + + // Wake again on the SAME dbUrl; data survived suspend. + expect(await query(a.dbUrl, "select x from t")).toContain("1"); + + // Fork inherits data, diverges independently. + const b = await fleet.forkPod("a", "b"); + await query(b.dbUrl, "insert into t values (2)"); + expect(await query(a.dbUrl, "select count(*)::int as n from t")).toContain("1"); + expect(await query(b.dbUrl, "select count(*)::int as n from t")).toContain("2"); + }, 600_000); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/fleet && FLEET_PG_TESTS=1 pnpm vitest run src/Fleet.integration.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `Fleet.ts`** + +```typescript +// src/Fleet.ts +import { homedir } from "node:os"; +import { join } from "node:path"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { + createStack, + writePreloadLibraries, + type StackHandle, +} from "@supabase/stack/bun"; +import { EdgeProxy, type PodUpstream } from "./EdgeProxy.ts"; +import { IdleMonitor } from "./IdleMonitor.ts"; +import { PodRegistry } from "./PodRegistry.ts"; +import { PortRegistry } from "./PortRegistry.ts"; +import { Provisioner, type CreatePodOptions } from "./Provisioner.ts"; +import { TemplateStore } from "./TemplateStore.ts"; +import type { PodManifest } from "./PodManifest.ts"; + +export interface FleetOptions { + readonly root?: string; + readonly idleMs?: number; +} + +export type PodState = "suspended" | "waking" | "warm" | "suspending"; + +export interface PodStatus { + readonly manifest: PodManifest; + readonly state: PodState; + readonly dbUrl: string; +} + +export interface FleetHandle extends AsyncDisposable { + createPod(opts: CreatePodOptions): Promise; + destroyPod(id: string): Promise; + resetPod(id: string): Promise; + forkPod(sourceId: string, newId: string): Promise; + wake(id: string): Promise; + suspend(id: string): Promise; + ensureExtensionPreload(id: string, extension: string): Promise; + listPods(): Promise>; + dispose(): Promise; +} + +interface WarmPod { + readonly stack: StackHandle; + readonly internalDbPort: number; +} + +const DB_PASSWORD = "postgres"; // matches supabase CLI local-dev convention + +export async function createFleet(opts: FleetOptions = {}): Promise { + const root = opts.root ?? join(homedir(), ".supabase"); + const idleMs = opts.idleMs ?? 5 * 60_000; + + const templates = new TemplateStore(join(root, "templates")); + const pods = new PodRegistry(join(root, "pods")); + const ports = await PortRegistry.load(join(root, "fleet-state.json")); + const provisioner = new Provisioner({ templates, pods, ports }); + + const states = new Map(); + const warm = new Map(); + const wakesInFlight = new Map>(); + + const monitor = new IdleMonitor({ + idleMs, + onIdle: (podId) => { + void suspend(podId).catch(() => {}); + }, + }); + + const proxy = new EdgeProxy({ + onActivity: (podId, _event, openConnections) => { + monitor.recordActivity(podId, openConnections); + }, + }); + + const dbUrl = (manifest: PodManifest): string => + `postgresql://postgres:${DB_PASSWORD}@127.0.0.1:${manifest.ports.dbPort}/postgres`; + + async function wakeUpstream(id: string): Promise { + const existing = warm.get(id); + if (existing) return { host: "127.0.0.1", port: existing.internalDbPort }; + const inFlight = wakesInFlight.get(id); + if (inFlight) return inFlight; + const p = (async (): Promise => { + const manifest = await pods.read(id); + if (manifest === undefined) throw new Error(`unknown pod: ${id}`); + states.set(id, "waking"); + // Internal port: derive deterministically from external dbPort to stay clash-free + // within the registry-owned range (external ports are even offsets; internal = +10000). + const internalDbPort = manifest.ports.dbPort + 10_000; + const stack = await createStack({ + stackRoot: join(pods.podDir(id), "stack"), + port: manifest.ports.apiPort + 10_000, + lazyServices: true, + postgres: { + dataDir: pods.dataDir(id), + version: manifest.versions.postgres, + port: internalDbPort, + provisioned: true, + profile: "micro", + }, + postgrest: manifest.services.postgrest === true ? {} : false, + auth: manifest.services.auth === true ? {} : false, + realtime: manifest.services.realtime === true ? {} : false, + edgeRuntime: manifest.services["edge-runtime"] === true ? {} : false, + storage: false, imgproxy: false, mailpit: false, pgmeta: false, + studio: false, analytics: false, vector: false, pooler: false, + functions: false, + }); + await stack.start(); + await stack.serviceReady("postgres"); + warm.set(id, { stack, internalDbPort }); + states.set(id, "warm"); + monitor.track(id); + monitor.recordActivity(id, proxy.openConnections(id)); + await writeFile(join(pods.podDir(id), "run.pid"), String(process.pid)); + wakesInFlight.delete(id); + return { host: "127.0.0.1", port: internalDbPort }; + })().catch((err: unknown) => { + wakesInFlight.delete(id); + states.set(id, "suspended"); + throw err; + }); + wakesInFlight.set(id, p); + return p; + } + + async function registerEdge(manifest: PodManifest): Promise { + states.set(manifest.id, "suspended"); + await proxy.register(manifest.id, manifest.ports.dbPort, () => wakeUpstream(manifest.id)); + } + + async function suspend(id: string): Promise { + const pod = warm.get(id); + if (!pod) return; + states.set(id, "suspending"); + monitor.untrack(id); + warm.delete(id); + await pod.stack.dispose(); + await rm(join(pods.podDir(id), "run.pid"), { force: true }); + states.set(id, "suspended"); + } + + async function status(manifest: PodManifest): Promise { + return { + manifest, + state: states.get(manifest.id) ?? "suspended", + dbUrl: dbUrl(manifest), + }; + } + + // Startup reconciliation: phase 1 policy is kill-then-suspend, not adoption. + // (Spec notes adoption as the goal; deferred — data is disposable and wake is fast.) + for (const manifest of await pods.list()) { + const pidRaw = await readFile(join(pods.podDir(manifest.id), "run.pid"), "utf8") + .catch(() => undefined); + if (pidRaw !== undefined) { + const pid = Number(pidRaw); + if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) { + try { process.kill(-pid, "SIGTERM"); } catch { /* group gone */ } + try { process.kill(pid, "SIGTERM"); } catch { /* gone */ } + } + await rm(join(pods.podDir(manifest.id), "run.pid"), { force: true }); + } + await registerEdge(manifest); + } + + const handle: FleetHandle = { + async createPod(opts) { + const manifest = await provisioner.create(opts); + await registerEdge(manifest); + return status(manifest); + }, + async destroyPod(id) { + await suspend(id); + await proxy.unregister(id); + states.delete(id); + await provisioner.destroy(id); + }, + async resetPod(id) { + await suspend(id); + await provisioner.reset(id); + }, + async forkPod(sourceId, newId) { + await suspend(sourceId); + const manifest = await provisioner.fork(sourceId, newId); + await registerEdge(manifest); + return status(manifest); + }, + async wake(id) { + await wakeUpstream(id); + }, + suspend, + async ensureExtensionPreload(id, extension) { + const pod = warm.get(id); + if (pod) { + await pod.stack.ensureExtensionPreload(extension); + return; + } + const manifest = await pods.read(id); + if (manifest === undefined) throw new Error(`unknown pod: ${id}`); + const { readPreloadLibraries } = await import("@supabase/stack/bun"); + const libs = await readPreloadLibraries(pods.dataDir(id)); + if (!libs.includes(extension)) { + await writePreloadLibraries(pods.dataDir(id), [...libs, extension]); + } + }, + async listPods() { + const manifests = await pods.list(); + return Promise.all(manifests.map((m) => status(m))); + }, + async dispose() { + for (const id of [...warm.keys()]) await suspend(id); + await proxy.close(); + }, + async [Symbol.asyncDispose]() { + await handle.dispose(); + }, + }; + return handle; +} +``` + +And `src/index.ts`: + +```typescript +export { createFleet } from "./Fleet.ts"; +export type { FleetHandle, FleetOptions, PodState, PodStatus } from "./Fleet.ts"; +export type { CreatePodOptions } from "./Provisioner.ts"; +export type { PodManifest } from "./PodManifest.ts"; +export { templateKey, baseTemplateKey } from "./PodManifest.ts"; +``` + +Note for the implementer: both warm and suspended pods use Stack's `configureExtensionPreload(dataDir, name)` module. Warm Stack handles additionally restart PostgreSQL when that module reports an update; suspended pods pick up the persisted configuration on their next wake. + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/fleet && FLEET_PG_TESTS=1 pnpm vitest run src/Fleet.integration.test.ts` +Expected: PASS (allow several minutes on first run for binary download + template build). + +- [ ] **Step 5: Commit** + +```bash +git add packages/fleet/src +git commit -m "feat(fleet): fleet facade with wake-on-connect and suspend-on-idle" +``` + +--- + +### Task 15: Density + lifecycle E2E and package README + +**Files:** +- Create: `packages/fleet/tests/fleetDensity.e2e.test.ts` +- Create: `packages/fleet/README.md` + +**Interfaces:** +- Consumes: `createFleet` (Task 14). +- Produces: the guardrail test from the spec ("Density E2E: 100 registered pods, wake a subset") scaled to CI reality, plus user-facing docs. + +- [ ] **Step 1: Write the E2E test** + +```typescript +// tests/fleetDensity.e2e.test.ts +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createFleet } from "../src/Fleet.ts"; + +const PG_VERSION = "17.6.1.143"; +const REGISTERED = Number(process.env.FLEET_E2E_PODS ?? 20); // 100+ locally, 20 in CI +const WARM = 3; + +describe.skipIf(!process.env.FLEET_PG_TESTS)("fleet density", () => { + it(`registers ${REGISTERED} pods, wakes ${WARM}, suspends cleanly`, async () => { + const root = await mkdtemp(join(tmpdir(), "fleet-density-")); + await using fleet = await createFleet({ root, idleMs: 60_000 }); + + // Registration is cheap: template built once, then CoW clones. + for (let i = 0; i < REGISTERED; i += 1) { + await fleet.createPod({ id: `pod-${i}`, versions: { postgres: PG_VERSION } }); + } + const all = await fleet.listPods(); + expect(all).toHaveLength(REGISTERED); + expect(all.every((p) => p.state === "suspended")).toBe(true); + + // Distinct external ports across the whole fleet. + const portSet = new Set(all.map((p) => p.manifest.ports.dbPort)); + expect(portSet.size).toBe(REGISTERED); + + // Wake a subset; the rest stay suspended (zero processes). + for (let i = 0; i < WARM; i += 1) await fleet.wake(`pod-${i}`); + const after = await fleet.listPods(); + expect(after.filter((p) => p.state === "warm")).toHaveLength(WARM); + expect(after.filter((p) => p.state === "suspended")).toHaveLength(REGISTERED - WARM); + + // Explicit suspend brings a pod back to zero. + await fleet.suspend("pod-0"); + const final = await fleet.listPods(); + expect(final.find((p) => p.manifest.id === "pod-0")?.state).toBe("suspended"); + }, 900_000); +}); +``` + +- [ ] **Step 2: Run it** + +Run: `cd packages/fleet && FLEET_PG_TESTS=1 pnpm vitest run tests/fleetDensity.e2e.test.ts` +Expected: PASS. Locally, re-run once with `FLEET_E2E_PODS=100` and record wall-clock + `ps` observations in the PR description (PSS harness is a later phase; this is the smoke-level guardrail). + +- [ ] **Step 3: Write README.md** + +```markdown +# @supabase/fleet + +Host-level daemon for running many lightweight Supabase pods in parallel: +CoW template provisioning, wake-on-connect, suspend-on-idle, instant fork. + +## Quick start + +​```ts +import { createFleet } from "@supabase/fleet"; + +const fleet = await createFleet(); +const pod = await fleet.createPod({ id: "my-worktree", versions: { postgres: "17.6.1.143" } }); +// pod.dbUrl is live immediately — the first connection wakes postgres (~200ms). +// After 5 idle minutes the pod suspends to zero processes; the port keeps listening. +const branch = await fleet.forkPod("my-worktree", "my-worktree-experiment"); +​``` + +Design: `docs/specs/2026-07-07-micro-supabase-stacks-design.md`. +Phase 1 limitations: native mode (macOS/Linux) only; daemon restart kills-then-resuspends +running pods instead of adopting them; HTTP service lazy-start requires `lazyServices: true`. +``` + +(Remove the zero-width escapes around the code fences when writing the real file.) + +- [ ] **Step 4: Full check** + +Run: `cd packages/fleet && pnpm vitest run --exclude '**/*.e2e.test.ts' && cd ../stack && pnpm vitest run --exclude '**/*.e2e.test.ts'` +Expected: everything green. Also run `pnpm check:all` in both packages and fix lint/format findings. + +- [ ] **Step 5: Commit** + +```bash +git add packages/fleet +git commit -m "test(fleet): density e2e and package README" +``` + +--- + +## Deferred to later phases (explicitly out of Phase 1) + +- Binary/Docker image optimizations (user direction: after this phase). +- CLI wiring of `supabase start/stop` onto `createFleet` (thin layer; do once fleet API settles). +- HTTP gateway host-based routing across pods + edge wake for the api port (Phase 1 wakes via the db port and per-service lazy start inside a warm pod). +- PSS/CPU benchmark harness with budget assertions in CI. +- True pod adoption across fleet-daemon restarts (Phase 1: kill-then-resuspend). +- Warm-template LRU garbage collection. +- `supautils` profile flag enforcement (manifest field exists; config plumbing later). +- Compatibility suite (`CREATE EXTENSION` sweep, dump/restore round-trip). +``` diff --git a/packages/process-compose/src/Orchestrator.ts b/packages/process-compose/src/Orchestrator.ts index ad24308ffa..7b78fe88c6 100644 --- a/packages/process-compose/src/Orchestrator.ts +++ b/packages/process-compose/src/Orchestrator.ts @@ -6,6 +6,7 @@ import { Exit, FiberMap, Layer, + PubSub, Context, Stream, SubscriptionRef, @@ -51,6 +52,9 @@ export class Orchestrator extends Context.Service< name: string, def: ServiceDef, ) => Effect.Effect; + readonly addServiceDefinitions: ( + defs: ReadonlyArray, + ) => Effect.Effect; readonly getState: (name: string) => Effect.Effect; readonly getAllStates: () => Effect.Effect>; readonly stateChanges: ( @@ -103,22 +107,30 @@ export class Orchestrator extends Context.Service< stoppedByUser: boolean; } const services = new Map(); + const allStateChangesPubSub = yield* PubSub.unbounded(); + + const addServiceSignals = (name: string) => + Effect.gen(function* () { + if (services.has(name)) return; + const stateRef = yield* SubscriptionRef.make(initial(name)); + services.set(name, { + state: stateRef, + started: Deferred.makeUnsafe(), + healthy: Deferred.makeUnsafe(), + completed: Deferred.makeUnsafe(), + stopped: Deferred.makeUnsafe(), + stoppedByUser: false, + }); + }); // Initialize all signal maps for all services in the graph for (const def of graph.startOrder) { - const stateRef = yield* SubscriptionRef.make(initial(def.name)); - services.set(def.name, { - state: stateRef, - started: Deferred.makeUnsafe(), - healthy: Deferred.makeUnsafe(), - completed: Deferred.makeUnsafe(), - stopped: Deferred.makeUnsafe(), - stoppedByUser: false, - }); + yield* addServiceSignals(def.name); } // FiberMap to track running service fibers — auto-interrupted on scope close const fibers = yield* FiberMap.make(); + const launchedServices = new Set(); // Helper: send a validated FSM event — only does the state transition const sendEvent = ( @@ -127,7 +139,11 @@ export class Orchestrator extends Context.Service< ): Effect.Effect => { const svc = services.get(name); if (svc === undefined) return Effect.succeed(null); - return transition(svc.state, event); + return transition(svc.state, event).pipe( + Effect.tap((state) => + state === null ? Effect.void : PubSub.publish(allStateChangesPubSub, state), + ), + ); }; // Helper: run all hooks for a given trigger in sequence @@ -530,14 +546,52 @@ export class Orchestrator extends Context.Service< const restartClosureFor = (name: string): ReadonlyArray => { const names = new Set([name]); + const visited = new Set([name]); + const traversedThrough = new Set(); const collectDependents = (current: string): void => { for (const dependent of graph.dependentsOf(current)) { - if (names.has(dependent.name)) continue; - names.add(dependent.name); + if (visited.has(dependent.name)) continue; + visited.add(dependent.name); + const svc = services.get(dependent.name); + if (svc === undefined) continue; + // A user-stopped service CUTS restart propagation: its own + // dependents no longer consume the restarted dependency through + // it, and restarting them would boot against the stopped backend + // (their dependency deferreds are stale-succeeded). + if (svc.stoppedByUser) continue; + const status = SubscriptionRef.getUnsafe(svc.state).status; + const shouldRestart = + status === "Pending" ? launchedServices.has(dependent.name) : status !== "Stopped"; + if (shouldRestart) { + names.add(dependent.name); + } else if ((dependent.restart ?? defaults.restart) === "no" && status === "Stopped") { + traversedThrough.add(dependent.name); + } collectDependents(dependent.name); } }; collectDependents(name); + // Completed one-shot helpers (Stopped but not user-stopped, e.g. + // postgres-init) are transparent to traversal, but when a live + // dependent behind one is restarting the helper must re-run too: + // otherwise that dependent's dependency wait would resolve against + // the helper's stale already-completed deferred and it would boot + // without waiting for the restarted dependency to be ready again. + const connectsToRestarting = (helper: string): boolean => { + const stack = graph.dependentsOf(helper).map((def) => def.name); + const seen = new Set(); + while (stack.length > 0) { + const current = stack.pop()!; + if (seen.has(current)) continue; + seen.add(current); + if (names.has(current)) return true; + stack.push(...graph.dependentsOf(current).map((def) => def.name)); + } + return false; + }; + for (const helper of traversedThrough) { + if (connectsToRestarting(helper)) names.add(helper); + } return graph.startOrder.filter((def) => names.has(def.name)); }; @@ -601,6 +655,7 @@ export class Orchestrator extends Context.Service< start: () => Effect.gen(function* () { for (const def of graph.startOrder) { + launchedServices.add(def.name); yield* FiberMap.run(fibers, def.name, runServiceSafe(def)); } }), @@ -613,6 +668,20 @@ export class Orchestrator extends Context.Service< } const order = graph.startOrderFor(name); for (const d of order) { + const svc = services.get(d.name); + const state = svc === undefined ? undefined : SubscriptionRef.getUnsafe(svc.state); + const status = state?.status; + if ( + (d.restart ?? defaults.restart) === "no" && + status === "Stopped" && + state?.exitCode === 0 + ) { + continue; + } + if (status === "Stopped" || status === "Failed") { + yield* resetService(d.name); + } + launchedServices.add(d.name); yield* FiberMap.run(fibers, d.name, runServiceSafe(d), { onlyIfMissing: true }); } }), @@ -668,6 +737,7 @@ export class Orchestrator extends Context.Service< }), ), ); + launchedServices.clear(); }), stopService: (name: string) => @@ -681,6 +751,7 @@ export class Orchestrator extends Context.Service< yield* FiberMap.remove(fibers, name); // Force Stopped if still in Stopping (fiber was interrupted before ProcessExited) yield* sendEvent(name, { _tag: "ProcessExited", exitCode: 143 }); + launchedServices.delete(name); }), restartService: (name: string) => @@ -698,6 +769,7 @@ export class Orchestrator extends Context.Service< yield* resetService(affectedDef.name); } for (const affectedDef of affected) { + launchedServices.add(affectedDef.name); yield* FiberMap.run(fibers, affectedDef.name, runServiceSafe(affectedDef)); } }), @@ -716,6 +788,18 @@ export class Orchestrator extends Context.Service< graph = nextGraph; }), + addServiceDefinitions: (defs: ReadonlyArray) => + Effect.gen(function* () { + const additions = defs.filter((def) => lookupDef(def.name) === undefined); + if (additions.length === 0) return; + const nextGraph = yield* buildGraph([...graph.startOrder, ...additions]); + for (const def of additions) { + yield* addServiceSignals(def.name); + yield* PubSub.publish(allStateChangesPubSub, initial(def.name)); + } + graph = nextGraph; + }), + getState: (name: string) => Effect.gen(function* () { const svc = services.get(name); @@ -742,13 +826,13 @@ export class Orchestrator extends Context.Service< return SubscriptionRef.changes(svc.state); }), - allStateChanges: () => { - const streams = graph.startOrder.map((def) => { - const svc = services.get(def.name); - return svc ? SubscriptionRef.changes(svc.state) : Stream.empty; - }); - return Stream.mergeAll(streams, { concurrency: "unbounded" }); - }, + allStateChanges: () => + Stream.concat( + Stream.fromIterable( + [...services.values()].map((signals) => SubscriptionRef.getUnsafe(signals.state)), + ), + Stream.fromPubSub(allStateChangesPubSub), + ), waitReady: (name: string) => Effect.gen(function* () { diff --git a/packages/process-compose/src/Orchestrator.unit.test.ts b/packages/process-compose/src/Orchestrator.unit.test.ts index 22a45f266b..409e5471e9 100644 --- a/packages/process-compose/src/Orchestrator.unit.test.ts +++ b/packages/process-compose/src/Orchestrator.unit.test.ts @@ -511,6 +511,49 @@ describe("Orchestrator", () => { }).pipe(Effect.provide(layer), Effect.scoped); }); + it.live("startService restarts a service that was stopped", () => { + const { layer, proc } = setupOrchestrator([svc("a")], { + exitDelay: "5 seconds", + }); + return Effect.gen(function* () { + const orc = yield* Orchestrator; + yield* orc.startService("a"); + yield* proc.waitForSpawnCount(1); + yield* orc.stopService("a"); + yield* orc.startService("a"); + yield* proc.waitForSpawnCount(2); + expect(proc.spawned.map((s) => s.command)).toEqual(["a", "a"]); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.live("startService does not rerun completed one-shot dependencies", () => { + const { layer, proc } = setupOrchestrator( + [ + svc("postgres-init", { restart: "no" }), + svc("api", { + dependencies: [{ service: "postgres-init", condition: "completed" }], + }), + ], + { + perService: { + api: { exitDelay: "5 seconds" }, + "postgres-init": { exitDelay: "10 millis" }, + }, + }, + ); + return Effect.gen(function* () { + const orc = yield* Orchestrator; + yield* orc.startService("api"); + yield* proc.waitForSpawn("api"); + yield* waitForStopped(orc, "postgres-init"); + + yield* orc.startService("api"); + yield* Effect.sleep(Duration.millis(50)); + + expect(proc.spawned.map((s) => s.command)).toEqual(["postgres-init", "api"]); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + it.live("restartService stops and restarts a service", () => { const { layer, proc } = setupOrchestrator([svc("a")], { exitDelay: "5 seconds", @@ -563,6 +606,152 @@ describe("Orchestrator", () => { }).pipe(Effect.provide(layer), Effect.scoped); }); + it.live("restartService skips transitive dependents that were never started", () => { + const { layer, proc } = setupOrchestrator( + [ + svc("db"), + svc("api", { + dependencies: [{ service: "db", condition: "started" }], + }), + ], + { exitDelay: "5 seconds" }, + ); + return Effect.gen(function* () { + const orc = yield* Orchestrator; + yield* orc.startService("db"); + yield* proc.waitForSpawnCount(1); + + yield* orc.restartService("db"); + yield* proc.waitForSpawnCount(2); + + expect(proc.spawned.map((s) => s.command)).toEqual(["db", "db"]); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.live("restartService re-runs one-shot helpers that connect to live dependents", () => { + const { layer, proc } = setupOrchestrator( + [ + svc("postgres"), + svc("postgres-init", { + restart: "no", + dependencies: [{ service: "postgres", condition: "started" }], + }), + svc("api", { + dependencies: [{ service: "postgres-init", condition: "completed" }], + }), + ], + { + perService: { + api: { exitDelay: "5 seconds" }, + postgres: { exitDelay: "5 seconds" }, + "postgres-init": { exitDelay: "10 millis" }, + }, + }, + ); + return Effect.gen(function* () { + const orc = yield* Orchestrator; + yield* orc.start(); + yield* proc.waitForSpawn("api"); + yield* waitForStopped(orc, "postgres-init"); + + yield* orc.restartService("postgres"); + yield* proc.waitForSpawn("api", 2); + + const spawnCounts = proc.spawned.reduce>((counts, record) => { + counts[record.command] = (counts[record.command] ?? 0) + 1; + return counts; + }, {}); + // postgres-init re-runs: api's "postgres-init completed" dependency must + // gate on a FRESH completion that itself waited for the restarted + // postgres, not on the stale pre-restart deferred. + expect(spawnCounts).toEqual({ + api: 2, + postgres: 2, + "postgres-init": 2, + }); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.live("restartService does not restart dependents beyond a user-stopped service", () => { + const { layer, proc } = setupOrchestrator( + [ + svc("postgres"), + svc("pgmeta", { + dependencies: [{ service: "postgres", condition: "started" }], + }), + svc("studio", { + dependencies: [{ service: "pgmeta", condition: "started" }], + }), + ], + { exitDelay: "5 seconds" }, + ); + return Effect.gen(function* () { + const orc = yield* Orchestrator; + yield* orc.start(); + yield* proc.waitForSpawn("studio"); + + // Explicitly stopping pgmeta must cut restart propagation: studio's + // only path to postgres runs through it, so restarting postgres should + // neither resurrect pgmeta nor restart studio against a stopped backend. + yield* orc.stopService("pgmeta"); + yield* orc.restartService("postgres"); + yield* proc.waitForSpawn("postgres", 2); + + const spawnCounts = proc.spawned.reduce>((counts, record) => { + counts[record.command] = (counts[record.command] ?? 0) + 1; + return counts; + }, {}); + expect(spawnCounts).toEqual({ + postgres: 2, + pgmeta: 1, + studio: 1, + }); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.live("restartService includes launched dependents that are still pending", () => { + let dbReady = false; + const { layer, proc } = setupOrchestrator( + [ + svc("db", { + healthCheck: { + probe: { + _tag: "Exec", + command: "db-ready", + args: [], + }, + periodSeconds: 0.01, + }, + }), + svc("api", { + dependencies: [{ service: "db", condition: "healthy" }], + }), + ], + { + perService: { + api: { exitDelay: "5 seconds" }, + db: { exitDelay: "5 seconds" }, + "db-ready": { getExitCode: () => (dbReady ? 0 : 1) }, + }, + }, + ); + return Effect.gen(function* () { + const orc = yield* Orchestrator; + yield* orc.start(); + yield* proc.waitForSpawn("db"); + expect((yield* orc.getState("api")).status).toBe("Pending"); + + yield* orc.restartService("db"); + dbReady = true; + yield* proc.waitForSpawn("api"); + + const spawnedServices = proc.spawned + .map((s) => s.command) + .filter((command) => command === "api" || command === "db"); + expect(spawnedServices).toEqual(["db", "db", "api"]); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + it.live("updateServiceDefinition restarts with the updated definition", () => { const { layer, proc } = setupOrchestrator([svc("a")], { exitDelay: "5 seconds", @@ -580,6 +769,28 @@ describe("Orchestrator", () => { }).pipe(Effect.provide(layer), Effect.scoped); }); + it.live("adds service definitions after the orchestrator has started", () => { + const { layer, proc } = setupOrchestrator([svc("db")], { + exitDelay: "5 seconds", + }); + return Effect.gen(function* () { + const orc = yield* Orchestrator; + yield* orc.startService("db"); + yield* proc.waitForSpawn("db"); + + yield* orc.addServiceDefinitions([ + svc("api", { + dependencies: [{ service: "db", condition: "healthy" }], + }), + ]); + yield* orc.startService("api"); + yield* proc.waitForSpawn("api"); + + expect(proc.spawned.map((record) => record.command)).toEqual(["db", "api"]); + expect((yield* orc.getState("api")).status).not.toBe("Pending"); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + it.live("stateChanges returns a stream of state transitions", () => { const { layer } = setupOrchestrator([svc("a")], { exitDelay: "200 millis", diff --git a/packages/process-compose/tests/helpers/mocks.ts b/packages/process-compose/tests/helpers/mocks.ts index 4d564db8af..a4a8bcc046 100644 --- a/packages/process-compose/tests/helpers/mocks.ts +++ b/packages/process-compose/tests/helpers/mocks.ts @@ -6,8 +6,66 @@ interface SpawnRecord { args: ReadonlyArray; } +interface SupervisorPayload { + readonly command: string; + readonly args: ReadonlyArray; +} + const encoder = new TextEncoder(); +/** + * Decode the supervisor payload (base64url JSON in the last arg - see + * `makeSupervisedCommand`) to recover the underlying service command and args. Returns + * `undefined` for non-supervised spawns (health-check probes, docker commands, + * etc.) whose last arg is not a supervisor payload. + */ +function decodeSupervisedPayload(args: ReadonlyArray): SupervisorPayload | undefined { + const encoded = args.at(-1); + if (encoded === undefined) return undefined; + try { + const decoded: unknown = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); + if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) { + return undefined; + } + const command = "command" in decoded ? decoded.command : undefined; + const innerArgs = "args" in decoded ? decoded.args : undefined; + if (typeof command !== "string" || !Array.isArray(innerArgs)) return undefined; + if (!innerArgs.every((arg) => typeof arg === "string")) return undefined; + return { command, args: innerArgs }; + } catch { + return undefined; + } +} + +/** + * Whether a spawn models a long-running daemon (postgres, postgrest, …) that + * stays alive until it is explicitly killed, versus a short-lived process + * (health-check probes like `pg_isready`, one-shot init scripts, docker + * commands) that exits on its own. + * + * This distinction is essential for fidelity to real processes. Real daemons do + * NOT exit ~immediately after spawning; if the mock had them exit after a fixed + * delay (as it once did), the orchestrator's `unless-stopped`/`always` restart + * loop would treat every daemon as a crash-looping process and churn through + * `RestartTriggered` → backoff → respawn cycles forever. That never happens in + * production (daemons run until stopped), so the projected status would never + * settle on Running/Healthy — an artifact that only the mock could produce. + * + * A supervised spawn (launched through `makeSupervisedCommand`, i.e. the + * supervisor runtime with a base64url payload) is treated as a long-running + * daemon. The explicit exception is one-shot shell scripts (`bash -c` / `sh -c`) + * such as `postgres-init`, which must exit so their `completed` signal fires. + * Native Postgres is also launched through `bash`, but its first arg is the + * bundled `supabase-postgres-init.sh` wrapper and it stays alive like Postgres + * does in production. + */ +function isLongRunningDaemon(args: ReadonlyArray): boolean { + const payload = decodeSupervisedPayload(args); + if (payload === undefined) return false; + const base = payload.command.split("/").pop() ?? payload.command; + return !((base === "bash" || base === "sh") && payload.args[0] === "-c"); +} + export function mockChildProcessSpawner( opts: { exitCode?: number; @@ -33,16 +91,23 @@ export function mockChildProcessSpawner( const exitDeferred = yield* Deferred.make(); let running = true; - yield* Effect.forkDetach( - Effect.gen(function* () { - yield* Effect.sleep("10 millis"); - running = false; - yield* Deferred.succeed( - exitDeferred, - ChildProcessSpawner.ExitCode(opts.exitCode ?? 0), - ); - }), - ); + const longRunning = isLongRunningDaemon(args); + + // Long-running daemons stay alive until killed (matching real processes); + // short-lived processes (probes, one-shot init scripts, docker commands) + // resolve their exit code after a real 10ms sleep so `it.live` clocks progress. + if (!longRunning) { + yield* Effect.forkDetach( + Effect.gen(function* () { + yield* Effect.sleep("10 millis"); + running = false; + yield* Deferred.succeed( + exitDeferred, + ChildProcessSpawner.ExitCode(opts.exitCode ?? 0), + ); + }), + ); + } const stdoutBytes = (opts.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); const stderrBytes = (opts.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); @@ -56,9 +121,12 @@ export function mockChildProcessSpawner( isRunning: Effect.sync(() => running), stdin: Sink.drain, kill: (killOpts) => - Effect.sync(() => { + Effect.gen(function* () { killed.push(killOpts?.killSignal ?? "SIGTERM"); running = false; + // Resolve the exit code so callers awaiting it (the orchestrator's + // restart/stop paths) unblock — a killed process reports 143 (128 + SIGTERM). + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(143)); }), unref: Effect.succeed(Effect.void), getInputFd: () => Sink.drain, diff --git a/packages/stack/README.md b/packages/stack/README.md index 2a2bc42ac6..3754284d90 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -1,377 +1,217 @@ # @supabase/stack -Programmatic local Supabase stack for TypeScript. Create a local Supabase runtime from code, then control lifecycle, status, and logs through a small async handle. +Programmatic local Supabase for TypeScript. `createStack()` returns a ready stack with Postgres +running and a stable local gateway. Supabase sidecars are available through that gateway and are +prepared and started only when first used. -## Features +## Quick start -- **Single entry point** -- `createStack()` resolves config and returns a handle; `start()` prepares assets, starts services, and waits for readiness -- **Preparation-aware startup** -- cold-cache startup can surface `Downloading` before normal runtime states like `Starting`, `Initializing`, and `Healthy` -- **Native binaries with Docker fallback** -- uses native services when available and falls back to Docker images automatically -- **Automatic port allocation** -- all ports are optional and auto-assigned to avoid conflicts -- **API proxy with opaque keys** -- SDKs use `publishableKey`/`secretKey` (like production), translated to JWTs internally -- **`AsyncDisposable` support** -- use `await using` for automatic cleanup -- **Streaming logs and status** -- real-time `AsyncIterable` streams for service state changes and log output -- **Per-service lifecycle control** -- start, stop, and restart individual services independently - -## Installation - -```sh -bun add @supabase/stack -``` - -## Quick Start - -```typescript +```ts +import { createClient } from "@supabase/supabase-js"; import { createStack } from "@supabase/stack"; -// Zero config — all settings have sensible defaults -const stack = await createStack(); -await stack.start(); - +await using stack = await createStack(); const supabase = createClient(stack.url, stack.publishableKey); -// ... -await stack.dispose(); -``` -### With explicit config +const { data, error } = await supabase.from("todos").select(); +``` -```typescript -import { createStack } from "@supabase/stack"; -import { createClient } from "@supabase/supabase-js"; +There is no second startup step: once `createStack()` resolves, Postgres and the local API gateway +are ready. The first request to `/rest/v1`, `/auth/v1`, `/storage/v1`, `/realtime/v1`, or another +service route prepares that service's binary or image, adds it to the supervisor, starts it, and +waits for readiness before forwarding the request. -const stack = await createStack({ - jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", - postgres: { dataDir: "./supabase-data" }, -}); +Use `await using` when possible. Otherwise call `await stack.dispose()` in `finally`; cleanup is +strict and rejects if processes, containers, or auto-managed paths could not be removed. -await stack.start(); +## Selecting services -// Use supabase-js like you would against a hosted project -const supabase = createClient(stack.url, stack.publishableKey); -const { data } = await supabase.from("todos").select("*"); +Postgres is always present. `services` explicitly controls which sidecars are available. When it +is omitted in `auto` or `docker` mode, every Supabase sidecar is available lazily. There are no +presets. -// Clean up -await stack.dispose(); +```ts +await using stack = await createStack({ + services: ["postgrest", "auth", "realtime"], +}); ``` -### With `await using` +Configuration for an unselected service is rejected, which keeps the effective stack explicit: -```typescript -{ - await using stack = await createStack({ - jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", - postgres: { dataDir: "./supabase-data" }, - }); - await stack.start(); - - // Use the stack... - // Automatic graceful shutdown when the block exits (even on throw) -} +```ts +await using stack = await createStack({ + services: ["postgrest", "auth"], + postgrest: { schemas: ["public", "api"] }, + auth: { siteUrl: "http://localhost:3000" }, +}); ``` -## Configuration - -`createStack` accepts a config object with shared settings at the top level and per-service settings nested under Supabase services such as `postgres`, `postgrest`, `auth`, `realtime`, `storage`, `studio`, and more. +`native` mode defaults to the native-capable `postgrest` and `auth` sidecars. Docker-only services +must be used with `auto` or `docker` mode. -### Top-level settings +## Starting services eagerly -| Field | Type | Required | Default | Description | -| ---------------- | -------------------------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mode` | `"native" \| "auto" \| "docker"` | No | `"auto"` | Resolution mode. `"native"` requires native binaries, `"auto"` tries native first and falls back to Docker, and `"docker"` uses Docker images for all services. | -| `jwtSecret` | `string` | No | | Secret for JWT signing (min 32 characters). Defaults to a well-known dev secret | -| `port` | `number` | No | | API proxy port (auto-allocated if omitted) | -| `publishableKey` | `string` | No | | Custom opaque publishable key | -| `secretKey` | `string` | No | | Custom opaque secret key | +Use `startServices` when a test needs selected services ready before `createStack()` returns. This +does not change which services are available. -### `postgres` - -Optional. When omitted, uses all defaults (ephemeral temp data directory, auto-allocated port). - -| Field | Type | Required | Description | -| --------- | -------- | -------- | ------------------------------------------------------------------------------------------- | -| `dataDir` | `string` | No | Directory for Postgres data (PGDATA). Ephemeral temp dir if omitted (cleaned up on dispose) | -| `port` | `number` | No | Postgres port (auto-allocated if omitted) | -| `version` | `string` | No | Postgres version (default: `17.6.1.081`) | +```ts +await using stack = await createStack({ + services: ["postgrest", "auth", "storage", "imgproxy"], + startServices: ["postgrest", "auth"], +}); +``` -### `postgrest` +Dependencies are activated automatically. For example, eagerly starting `imgproxy` also starts +`storage`, but both must be listed in `services`. -Optional. Omit to include with defaults, set to `false` to exclude. +## Parallel tests -| Field | Type | Default | Description | -| ----------------- | ---------- | -------------------------- | ----------------------------------------- | -| `schemas` | `string[]` | `["public"]` | Database schemas to expose | -| `extraSearchPath` | `string[]` | `["public", "extensions"]` | Additional Postgres `search_path` entries | -| `maxRows` | `number` | `1000` | Maximum rows returned per request | -| `version` | `string` | `14.5` | PostgREST version | +Every omitted port is allocated automatically. Allocation is leased through a cross-process +registry, so independent test workers can safely create stacks concurrently. -### `auth` +```ts +import { afterAll, beforeAll, describe, test } from "vitest"; +import { createStack, type StackHandle } from "@supabase/stack"; -Optional. Omit to include with defaults, set to `false` to exclude. +describe.concurrent("feature", () => { + let stack: StackHandle; -| Field | Type | Default | Description | -| ------------- | -------- | -------------------------- | ---------------------------------- | -| `port` | `number` | auto | Auth service port | -| `siteUrl` | `string` | `http://localhost:3000` | Auth redirect URL (your app's URL) | -| `jwtExpiry` | `number` | `3600` | JWT expiry in seconds | -| `externalUrl` | `string` | `http://127.0.0.1:${port}` | Auth external URL | -| `version` | `string` | `2.188.0-rc.15` | Auth version | + beforeAll(async () => { + stack = await createStack({ services: ["postgrest"] }); + }); -### Full config example + afterAll(async () => { + await stack.dispose(); + }); -```typescript -const stack = await createStack({ - jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", - port: 54321, - postgres: { port: 54322, dataDir: "/tmp/data", version: "17.6.1.081" }, - postgrest: { schemas: ["public", "custom"], maxRows: 500, version: "14.5" }, - auth: { port: 9999, siteUrl: "http://myapp.dev:3000", jwtExpiry: 7200 }, + test("uses an isolated database", async () => { + // stack.dbUrl and stack.url are unique to this stack + }); }); ``` -## Docker Mode +Provide `postgres.dataDir` when data should survive disposal. Otherwise Stack creates and removes +an isolated temporary data directory. -Set `mode: "docker"` to force all services to run in Docker containers, bypassing native binary resolution: +## Configuration -```typescript +```ts const stack = await createStack({ - mode: "docker", + mode: "auto", // "native" | "auto" | "docker" + port: 54321, // gateway port; optional + postgres: { + port: 54322, // optional + dataDir: "./supabase-data", // optional + version: "17.6.1.143", + }, + services: ["postgrest", "auth", "edge-runtime"], + startServices: ["postgrest"], + projectDir: ".", + functions: { noVerifyJwt: false }, }); ``` -This is useful for: +`auto` prefers native binaries and falls back to Docker. `docker` forces containers. All explicit +ports are checked and leased atomically before startup; a conflict rejects creation. -- Environments where native binaries aren't available -- Testing Docker-based service behavior -- CI/CD pipelines that prefer containerized services +## Stack handle -Docker mode requires Docker to be installed and running. +Connection properties: -## Stack API +- `url`: stable HTTP and WebSocket gateway URL +- `dbUrl`: PostgreSQL connection string +- `publishableKey`: opaque client key accepted by the local gateway +- `secretKey`: opaque privileged key accepted by the local gateway -### Connection Info +Lifecycle methods: -| Property | Type | Description | -| ---------------- | -------- | --------------------------------------------- | -| `url` | `string` | API proxy URL (e.g. `http://127.0.0.1:54321`) | -| `dbUrl` | `string` | PostgreSQL connection string | -| `publishableKey` | `string` | Opaque API key for `supabase-js` | -| `secretKey` | `string` | Opaque API key for privileged operations | - -### Lifecycle - -```typescript -await stack.start(); // Prepare assets, start all services, block until ready -await stack.stop(); // Graceful dependency-ordered shutdown -await stack.dispose(); // stop() + release runtime resources -``` - -`dispose()` is also called automatically by `[Symbol.asyncDispose]` when using `await using`. - -Calling `stop()` or `dispose()` multiple times is safe -- all operations are idempotent. - -On a cold cache, `start()` may spend time downloading binaries or pulling Docker images before any -service process exists. During that phase, `getStatus()` / `statusChanges()` can surface -`Downloading` for the affected public services. - -### Per-Service Lifecycle - -```typescript -await stack.stopService("auth"); // Stop a single service -await stack.startService("auth"); // Restart it (blocks until ready) -await stack.restartService("auth"); // Stop + start in one call +```ts +await stack.stop(); +await stack.start(); // restart after stop; waits for Postgres and startServices +await stack.startService("auth"); +await stack.stopService("auth"); +await stack.restartService("auth"); +await stack.dispose(); ``` -Common service names include `"postgres"`, `"postgrest"`, `"auth"`, `"realtime"`, `"storage"`, -`"imgproxy"`, `"mailpit"`, `"pgmeta"`, `"studio"`, `"analytics"`, `"vector"`, and `"pooler"`. - -Internal helper processes are projected away from the public stack API. For example, `postgres-init` -is treated as an implementation detail of `postgres`, so callers only see the public `postgres` -service and its projected status. +Readiness, status, and logs: -### Readiness - -```typescript -await stack.ready(); // Wait for all services -await stack.ready({ timeout: 30_000 }); // With timeout (ms) -await stack.serviceReady("postgres"); // Wait for one service +```ts +await stack.ready({ timeout: 30_000 }); await stack.serviceReady("auth", { timeout: 10_000 }); -``` - -Note: `start()` already blocks until all services are ready. Use `ready()` and `serviceReady()` after manually starting individual services. - -### Status - -```typescript -const statuses = await stack.getStatus(); // All public services -const status = await stack.getServiceStatus("auth"); // One public service - -// Stream real-time state changes -for await (const state of stack.statusChanges()) { - console.log(`${state.name}: ${state.status}`); -} -``` - -`StackServiceState` includes the public service `name`, projected `status` (for example -`"Downloading"`, `"Healthy"`, or `"Initializing"`), process metadata, and any surfaced error. - -### Logs - -```typescript -// Stream all logs in real time -for await (const entry of stack.logs()) { - console.log(`[${entry.service}] ${entry.message}`); -} - -// Stream logs for a specific service -for await (const entry of stack.serviceLogs("postgres")) { - console.log(entry.message); -} -// Get buffered log history -const history = await stack.logHistory("auth", 100); +const status = await stack.getStatus(); +for await (const state of stack.statusChanges()) console.log(state); +for await (const entry of stack.logs()) console.log(entry); +const recentAuthLogs = await stack.logHistory("auth", 100); ``` -## Platform Support +`ready()` waits only for services that were actually started. Available lazy services remain +`Pending` until first use or an explicit `startService()` call. -The package uses export conditions so Bun and Node.js consumers import from the same root: - -```typescript -import { createStack } from "@supabase/stack"; -``` - -The runtime selects the Bun or Node.js implementation automatically. Both expose the same `createStack(config): Promise` API. +For preload-required extensions, `ensureExtensionPreload(name)` persists the required +`shared_preload_libraries` entry and restarts Postgres only when necessary. It does not execute +`CREATE EXTENSION`. ## Prefetching -Pre-download binaries and Docker images for all services before they're needed — useful in test `globalSetup` to avoid download delays during test execution: +Prefetch assets in a test runner's global setup when cold-start download time is undesirable: -```typescript -// vitest.config.ts globalSetup +```ts import { prefetch } from "@supabase/stack"; -export async function setup() { - await prefetch(); -} +await prefetch({ services: ["postgres", "postgrest", "auth"] }); ``` -Prefetch specific services or versions: - -```typescript -await prefetch({ mode: "docker" }); -await prefetch({ services: ["postgres", "postgrest"] }); -await prefetch({ versions: { postgres: "17.4.1.045" } }); -``` +Prefetching is optional; normal stack startup and first-use activation prepare only what is needed. -## Service Versions +## Advanced entry points -Default versions are used when no `version` field is specified per service: +The root package is the primary public interface and selects Bun or Node automatically: -| Service | Default Version | -| --------- | --------------- | -| Postgres | `17.6.1.081` | -| PostgREST | `14.5` | -| Auth | `2.188.0-rc.15` | - -Override versions per service: - -```typescript -const stack = await createStack({ - jwtSecret: "...", - postgres: { dataDir: "/tmp/data", version: "17.4.1.045" }, - postgrest: { version: "14.4" }, - auth: { version: "2.180.0" }, -}); +```ts +import { createStack } from "@supabase/stack"; ``` -## Error Handling +Infrastructure that already owns a pre-initialized Postgres data directory can use the deeper +constructor. It is intentionally kept off the root interface: -All `Stack` methods throw `StackError` on failure, a standard `Error` subclass with a `code` field: +```ts +import { createProvisionedStack } from "@supabase/stack/provisioned"; -```typescript -import { StackError } from "@supabase/stack"; - -try { - await stack.startService("nonexistent"); -} catch (err) { - if (err instanceof StackError) { - console.error(err.code); // "SERVICE_NOT_FOUND" - console.error(err.message); // Human-readable description - } -} +const stack = await createProvisionedStack({ + stackRoot: "/var/lib/supabase/pods/example/stack", + dataDir: "/var/lib/supabase/pods/example/data", + postgresPassword: "postgres", + services: ["postgrest"], +}); ``` -| Code | Description | -| ------------------- | -------------------------------------------- | -| `SERVICE_NOT_FOUND` | Referenced a service that doesn't exist | -| `SERVICE_NOT_READY` | Service failed to become healthy | -| `BUILD_ERROR` | Failed to build the service dependency graph | -| `BINARY_NOT_FOUND` | No binary available for the current platform | -| `DOWNLOAD_ERROR` | Binary download failed | -| `PORT_CONFLICT` | Requested port is already in use | -| `PORT_ALLOCATION` | Failed to allocate a free port | - -## Examples +Effect consumers can use `@supabase/stack/effect` for the lower-level stopped controller and +layer APIs. -### Test setup with `beforeAll` / `afterAll` - -```typescript -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { createStack } from "@supabase/stack"; -import { createClient } from "@supabase/supabase-js"; +Long-lived hosts that manage many named stacks can use Fleet. It keeps stable database and API +endpoints while entire stacks suspend, and adds copy-on-write provisioning, reset, and fork: -describe("my app", () => { - let stack; - let supabase; +```ts +import { createFleet } from "@supabase/stack/fleet"; - beforeAll(async () => { - stack = await createStack({ - jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", - postgres: { dataDir: "/tmp/test-supabase" }, - }); - await stack.start(); - supabase = createClient(stack.url, stack.publishableKey); - }, 120_000); - - afterAll(async () => { - await stack?.dispose(); - }, 30_000); - - test("queries data", async () => { - const { data, error } = await supabase.from("todos").select("*"); - expect(error).toBeNull(); - }); -}); +await using fleet = await createFleet(); +const pod = await fleet.createPod({ start: false }); ``` -### Streaming logs during debugging - -```typescript -const stack = await createStack({ - jwtSecret: "...", - postgres: { dataDir: "/tmp/data" }, -}); -await stack.start(); - -// Print postgres logs as they arrive -for await (const entry of stack.serviceLogs("postgres")) { - process.stdout.write(entry.message + "\n"); -} -``` +Fleet is intended for CLI-daemon and environment-hosting workloads. Prefer direct `createStack()` +instances for ordinary parallel tests; see [Fleet](./docs/fleet.md) for the decision guide. -### Excluding services +## Errors -```typescript -const stack = await createStack({ - jwtSecret: "...", - postgres: { dataDir: "/tmp/data" }, - auth: false, // Only run Postgres and PostgREST -}); -``` +Public methods reject with `StackError`, whose `code` distinguishes failures such as +`SERVICE_NOT_FOUND`, `SERVICE_NOT_READY`, `BUILD_ERROR`, `BINARY_NOT_FOUND`, `DOWNLOAD_ERROR`, +`PORT_CONFLICT`, and `PORT_ALLOCATION`. ## Architecture -For a detailed look at internals, see: - -- [docs/architecture.md](./docs/architecture.md) -- [docs/detach-mode.md](./docs/detach-mode.md) -- [docs/resource-leak-mitigations.md](./docs/resource-leak-mitigations.md) +- [Architecture](./docs/architecture.md) +- [Fleet](./docs/fleet.md) +- [Detached mode](./docs/detach-mode.md) +- [Resource cleanup](./docs/resource-leak-mitigations.md) diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 75d39fcf93..364c7cf5e4 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture of `@supabase/stack` -Manages a local Supabase development stack — resolving native binaries, wiring services into a dependency graph, and exposing a single async `createStack()` call that returns running connection details. +Manages a local Supabase development stack — resolving native binaries, wiring services into a dependency graph, and exposing deep constructors that return running connection details. ## Table of contents @@ -850,19 +850,28 @@ metadata persisted separately for crash recovery. **File:** `src/createStack.ts` -`createStack` is the platform-agnostic core. It wires all layers, delegates to a `ManagedRuntime`, and returns a rich `Stack` interface. It takes a `PlatformFactory` parameter — a function `(apiPort: number) => PlatformLayer` — so the platform-specific HTTP server (Bun or Node.js) can be bound to the already-resolved port. Platform-specific layers (`BunHttpServer`, `NodeHttpServer`) are provided by the entry points (`bun.ts`, `node.ts`), not baked in. +`createStackController` is the platform-agnostic stopped-controller core. It wires all layers and +delegates to a `ManagedRuntime`. The public `createStack` entry points call `createReadyStack`, +which starts the controller and returns only after Postgres and the gateway are ready. The core +takes a `PlatformFactory` parameter so the platform-specific HTTP server can bind the resolved +gateway port. `createStack` also owns `resolveConfig()`, the internal async function that turns a raw `StackConfig` into a `ResolvedStackConfig`: it allocates ports via `PortAllocator`, generates JWTs via `generateJwt()` from `JwtGenerator.ts`, creates an ephemeral temp directory if no `dataDir` was specified, and applies all service config defaults. -Once the runtime is built, `stack.start()` now means: +The initial public `createStack()` call, and `stack.start()` after a later `stop()`, mean: -1. prepare assets via `StackPreparation` +1. prepare only Postgres via `StackPreparation` 2. publish synthetic `Downloading` states on cache misses -3. build the orchestrator through `StackBuilder` -4. start services and wait for health through `StackLifecycleCoordinator` +3. build the initial Postgres graph through `StackBuilder` +4. start Postgres and any explicitly selected `startServices` +5. wait for started services through `StackLifecycleCoordinator` + +Available sidecars are not prepared up front. On first use the gateway asks the coordinator to +prepare the sidecar and its dependencies, build their definitions, add those definitions to the +live process-compose orchestrator, start them, and wait for readiness before forwarding traffic. #### PlatformLayer type @@ -899,6 +908,7 @@ interface Stack extends AsyncDisposable { startService(name: string): Promise; stopService(name: string): Promise; restartService(name: string): Promise; + ensureExtensionPreload(name: string): Promise; // Status getStatus(): Promise>; @@ -918,7 +928,12 @@ interface Stack extends AsyncDisposable { [Symbol.asyncDispose](): Promise; } -async function createStack( +async function createStackController( + config: StackConfig | undefined, + platformFactory: PlatformFactory, +): Promise; + +async function createReadyStack( config: StackConfig | undefined, platformFactory: PlatformFactory, ): Promise; @@ -965,17 +980,27 @@ Streams (`statusChanges`, `logs`, `serviceLogs`) are converted to `AsyncIterable **Files:** `src/bun.ts`, `src/node.ts` -These thin wrappers are the runtime-specific implementations selected by the package root export conditions. Each one constructs the platform-specific layer and delegates to `createStack` from `createStack.ts`. +These thin wrappers are the runtime-specific adapters selected by the package root export +conditions. Each constructs the platform layer and delegates to `createReadyStack`. + +The package root exposes only the ready `createStack(config)` constructor. The advanced Effect +entry point names its stopped constructor `createStackController`, making the lifecycle difference +explicit. The deeper `createProvisionedStack(options)` constructor lives at +`@supabase/stack/provisioned`; the Fleet module can use pre-initialized data directories without +putting infrastructure concerns on the primary interface. + +Fleet is part of this package but stays behind the `@supabase/stack/fleet` subpath. Its +implementation lives in `src/fleet/` and owns host-level manifests, templates, stable outer ports, +idle suspension, reset, and fork. It consumes Stack through the same public and provisioned +interfaces as any other long-lived host, keeping service-level activation local to Stack while +leaving the root interface optimized for directly owned runtimes. ```ts // bun.ts import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; -export async function createStack(config?: StackConfig): Promise { - return createStackCore( - config, - (apiPort) => BunHttpServer.layer({ port: apiPort }) as unknown as PlatformLayer, - ); +export async function createStack(config?: StackConfig): Promise { + return createReadyStack(config, bunPlatformFactory); } ``` @@ -983,14 +1008,8 @@ export async function createStack(config?: StackConfig): Promise { // node.ts import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -export async function createStack(config?: StackConfig): Promise { - return createStackCore(config, (apiPort) => { - const spawnerLayer = NodeChildProcessSpawnerLayer.pipe( - Layer.provide(Layer.mergeAll(NodeFileSystemLayer, NodePathLayer)), - ); - const httpServerLayer = NodeHttpServer.layer(() => createServer(), { port: apiPort }); - return Layer.mergeAll(httpServerLayer, spawnerLayer) as unknown as PlatformLayer; - }); +export async function createStack(config?: StackConfig): Promise { + return createReadyStack(config, nodePlatformFactory); } ``` diff --git a/packages/stack/docs/detach-mode.md b/packages/stack/docs/detach-mode.md index 3e2695439c..1cb987de9b 100644 --- a/packages/stack/docs/detach-mode.md +++ b/packages/stack/docs/detach-mode.md @@ -167,7 +167,7 @@ There are two layers of API: - **`Stack`** (Effect Service) — used by CLI and other Effect consumers. Returns `Effect`s and `Stream`s. This is the internal API. - **`createStack()` handle** (Promise-based) — used by non-Effect library consumers. - Returns `Promise`s and `AsyncIterable`s. This public API is unchanged. + Returns a ready Stack with `Promise` methods and `AsyncIterable` streams. `RemoteStack` implements the same `Stack` Effect Service interface, but backed by HTTP/SSE over a Unix socket instead of in-process orchestration. The CLI switches @@ -188,8 +188,9 @@ Effect.gen(function* () { }); ``` -`stack.start()` now means `prepare assets -> publish Downloading when needed -> start services -> -wait healthy`, so detached mode exposes the same pre-runtime status behavior as foreground mode. +`stack.start()` means `prepare Postgres -> publish Downloading when needed -> start Postgres and +startServices -> wait healthy`. Sidecars are prepared and installed into the live supervisor when +the gateway or caller first requests them. `RemoteStack` translates each Effect/Stream method to the corresponding HTTP call: | Stack method | RemoteStack transport | diff --git a/packages/stack/docs/fleet.md b/packages/stack/docs/fleet.md new file mode 100644 index 0000000000..798b3569a6 --- /dev/null +++ b/packages/stack/docs/fleet.md @@ -0,0 +1,129 @@ +# Fleet + +Host-level manager for running many isolated local Supabase stacks. Fleet adds copy-on-write data +templates, stable wake proxies, whole-stack scale-to-zero, idle suspension, reset, and fork on top +of the direct Stack runtime. It is exported from `@supabase/stack/fleet` and is designed to run +inside a long-lived host such as the Supabase CLI daemon. + +Use `createStack()` for ordinary programmatic and parallel integration tests. Use Fleet when named +pods need stable endpoints while suspended, when environments need cheap reset/fork operations, or +when a long-lived host manages many more registered environments than it keeps warm. + +## Quick start + +```ts +import { createClient } from "@supabase/supabase-js"; +import { createFleet } from "@supabase/stack/fleet"; + +await using fleet = await createFleet(); +const pod = await fleet.createPod(); + +const supabase = createClient(pod.url, pod.publishableKey); +const { data } = await supabase.from("todos").select(); +``` + +`createPod()` returns a ready pod by default. Postgres and the stack-owned gateway are running; +sidecars are prepared and started lazily by the inner Stack on first HTTP or Realtime WebSocket +request. Every pod gets stable database and API endpoints that survive Fleet suspend/wake cycles. + +`createFleet()` owns its root exclusively and remains attached to the calling process. It does not +discover or launch a global daemon. A CLI daemon can host one Fleet and expose a remote control +interface to other processes without making that indirection part of `createStack()`. + +## Tests + +Prefer one directly owned `createStack()` per test worker or suite. Stack already provides +cross-process port leases, isolated temporary data directories, lazy sidecars, and strict cleanup, +so it is the simplest interface for many genuinely concurrent warm stacks. + +Fleet is useful in tests with a different shape: + +- hundreds of registered environments but only a small warm working set; +- repeated reset or copy-on-write fork from an expensive database fixture; +- external subprocesses that need endpoints to remain stable across suspend/wake; +- density, lifecycle, or CLI-daemon integration tests for Fleet itself. + +Do not create a Fleet per parallel test worker against the same root. Fleet intentionally has one +owner per root; cross-process consumers should talk to a single daemon-hosted Fleet instead. + +## Explicit services + +All sidecars are available lazily by default. Use `services` to select an exact set; there are no +presets. + +```ts +const pod = await fleet.createPod({ + id: "checkout-tests", + services: ["postgrest", "auth", "realtime"], +}); +``` + +Set `start: false` to register a suspended pod without launching its Stack. The first database, +HTTP, or WebSocket connection wakes it transparently: + +```ts +const pod = await fleet.createPod({ start: false }); +``` + +## API + +```ts +interface CreatePodOptions { + readonly id?: string; // random UUID by default + readonly services?: ReadonlyArray; // all by default + readonly versions?: Partial; + readonly warmTemplate?: boolean; // true by default + readonly start?: boolean; // true by default + readonly projectDir?: string; + readonly functions?: FunctionsConfig | false; +} + +interface FleetHandle extends AsyncDisposable { + createPod(opts?: CreatePodOptions): Promise; + destroyPod(id: string): Promise; + resetPod(id: string): Promise; + forkPod(sourceId: string, newId: string): Promise; + wake(id: string): Promise; + suspend(id: string): Promise; + ensureExtensionPreload(id: string, extension: string): Promise; + listPods(): Promise>; + dispose(): Promise; +} +``` + +`PodStatus` includes `id`, `state`, `url`, `dbUrl`, `publishableKey`, and `secretKey`. State is one +of `suspended`, `waking`, `warm`, or `suspending`. + +## Ownership boundary + +- Stack owns one warm Supabase runtime: Postgres, the API gateway, lazy asset preparation, + dependency activation, HTTP routing, Realtime WebSockets, service lifecycle, and strict cleanup. +- Fleet owns many Stack runtimes: durable manifests, race-safe public port allocation, copy-on-write + templates, wake deduplication, stable outer TCP proxies, idle suspension, reset, and fork. + +The outer proxy never needs to understand Supabase routes. It wakes the pod and forwards the raw +database or API connection to the inner Stack, which remains the single owner of service-level +behavior. + +## Lifecycle + +- `createPod()` clones a service/version-specific warm template by default and starts only the + minimal Stack runtime. +- `suspend()` disposes the inner Stack and verifies that the Postgres process group is gone while + keeping the two public listeners bound. +- A connection to either listener deduplicates concurrent wake attempts, creates a new inner Stack, + and forwards buffered bytes after it is ready. +- Pods with no open connections suspend after `idleMs` (five minutes by default). +- `forkPod()` takes an offline copy-on-write snapshot and restores the source's prior warm state. + +## Current constraints + +- Fleet provisioned pods use native Postgres on macOS and Linux; Docker-only sidecars can still be + launched lazily by the inner Stack. +- Daemon restart reconciliation is kill-then-suspend rather than live process adoption. Stale + Postgres process groups are reaped and the next connection wakes a fresh Stack against the same + disposable data directory. +- Warm-template garbage collection and compatibility/benchmark suites remain future work. + +See [the design specification](../../docs/specs/2026-07-07-micro-supabase-stacks-design.md) for +additional context. diff --git a/packages/stack/package.json b/packages/stack/package.json index 3b6b7c3e9e..3c9df4a013 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -8,6 +8,11 @@ "bun": "./src/bun.ts", "default": "./src/node.ts" }, + "./provisioned": { + "bun": "./src/provisioned-bun.ts", + "default": "./src/provisioned-node.ts" + }, + "./fleet": "./src/fleet/index.ts", "./effect": "./src/effect.ts", "./daemon-bun": "./src/daemon-bun.ts" }, diff --git a/packages/stack/src/ApiProxy.ts b/packages/stack/src/ApiProxy.ts index 12f258235e..56cd4df1e2 100644 --- a/packages/stack/src/ApiProxy.ts +++ b/packages/stack/src/ApiProxy.ts @@ -9,6 +9,8 @@ import { HttpServerRequest, HttpServerResponse, } from "effect/unstable/http"; +import * as Socket from "effect/unstable/socket/Socket"; +import type { ServiceName } from "./versions.ts"; export interface ProxyConfig { readonly listenPort: number; @@ -22,10 +24,17 @@ export interface ProxyConfig { readonly analyticsPort: number; readonly poolerPort: number; readonly studioPort: number; + readonly imgproxyEnabled?: boolean; readonly publishableKey: string; readonly secretKey: string; readonly anonJwt: string; readonly serviceRoleJwt: string; + /** + * Invoked with a route's owning service before the request is + * forwarded. Expected to be idempotent — it should start the service on first call and resolve + * immediately on later calls once the service is ready. + */ + readonly ensureService?: (name: ServiceName) => Promise; } function transformAuthorization( @@ -114,6 +123,8 @@ const COLD_START_RETRY_SCHEDULE = Schedule.spaced("250 millis").pipe(Schedule.ta interface ProxyHandlerOptions { readonly backendPort: number; + readonly service: ServiceName; + readonly additionalServices?: ReadonlyArray; readonly stripPrefix?: string; readonly backendPath?: string; readonly transformAuth?: boolean; @@ -125,6 +136,38 @@ interface ProxyHandlerOptions { readonly retryColdStart?: boolean; } +function ensureServices( + config: ProxyConfig, + services: ReadonlyArray, +): Effect.Effect { + if (config.ensureService === undefined) { + return Effect.succeed(undefined); + } + + return Effect.gen(function* () { + for (const service of services) { + const ensured = yield* Effect.result(Effect.tryPromise(() => config.ensureService!(service))); + if (Result.isFailure(ensured)) { + return HttpServerResponse.text(`Bad gateway: failed to start ${service}`, { + status: 502, + }); + } + } + return undefined; + }); +} + +function proxyPath(req: HttpServerRequest.HttpServerRequest, opts: ProxyHandlerOptions): string { + if (opts.backendPath !== undefined) { + return opts.backendPath; + } + + const path = req.url.startsWith(opts.stripPrefix ?? "") + ? req.url.slice((opts.stripPrefix ?? "").length) + : req.url; + return path === "" ? "/" : path; +} + function makeProxyHandler( client: HttpClient.HttpClient, config: ProxyConfig, @@ -132,17 +175,16 @@ function makeProxyHandler( ) { return (req: HttpServerRequest.HttpServerRequest) => Effect.gen(function* () { - let backendPath = opts.backendPath; - - if (backendPath === undefined) { - backendPath = req.url.startsWith(opts.stripPrefix ?? "") - ? req.url.slice((opts.stripPrefix ?? "").length) - : req.url; - if (backendPath === "") { - backendPath = "/"; - } + const unavailable = yield* ensureServices(config, [ + opts.service, + ...(opts.additionalServices ?? []), + ]); + if (unavailable !== undefined) { + return unavailable; } + const backendPath = proxyPath(req, opts); + let outHeaders = req.headers; if (opts.transformAuth === true) { outHeaders = transformAuthorization(outHeaders, config, opts.transformAuthCustomHeader); @@ -201,6 +243,54 @@ function makeProxyHandler( ); } +function makeRealtimeHandler(client: HttpClient.HttpClient, config: ProxyConfig) { + const opts: ProxyHandlerOptions = { + backendPort: config.realtimePort, + service: "realtime", + stripPrefix: "/realtime/v1", + }; + const httpHandler = makeProxyHandler(client, config, opts); + + return (req: HttpServerRequest.HttpServerRequest) => { + if (req.headers["upgrade"]?.toLowerCase() !== "websocket") { + return httpHandler(req); + } + + return Effect.gen(function* () { + const unavailable = yield* ensureServices(config, ["realtime"]); + if (unavailable !== undefined) { + return unavailable; + } + + const incomingResult = yield* Effect.result(req.upgrade); + if (Result.isFailure(incomingResult)) { + return HttpServerResponse.text("Bad gateway: websocket upgrade failed", { status: 502 }); + } + + const protocols = req.headers["sec-websocket-protocol"] + ?.split(",") + .map((protocol) => protocol.trim()) + .filter((protocol) => protocol.length > 0); + const backend = yield* Socket.makeWebSocket( + `ws://127.0.0.1:${config.realtimePort}${proxyPath(req, opts)}`, + { + closeCodeIsError: (code) => code !== 1000 && code !== 1001, + protocols, + }, + ).pipe(Effect.provide(Socket.layerWebSocketConstructorGlobal)); + const writeIncoming = yield* incomingResult.success.writer; + const writeBackend = yield* backend.writer; + + yield* Effect.raceFirst( + incomingResult.success.runRaw(writeBackend), + backend.runRaw(writeIncoming), + ).pipe(Effect.ignore); + + return HttpServerResponse.empty(); + }); + }; +} + export class ApiProxy extends Context.Service< ApiProxy, { @@ -222,6 +312,7 @@ export class ApiProxy extends Context.Service< "/.well-known/oauth-authorization-server", makeProxyHandler(client, config, { backendPort: config.gotruePort, + service: "auth", backendPath: "/.well-known/oauth-authorization-server", }), ), @@ -230,6 +321,7 @@ export class ApiProxy extends Context.Service< "/auth/v1/verify", makeProxyHandler(client, config, { backendPort: config.gotruePort, + service: "auth", stripPrefix: "/auth/v1", }), ), @@ -238,6 +330,7 @@ export class ApiProxy extends Context.Service< "/auth/v1/callback", makeProxyHandler(client, config, { backendPort: config.gotruePort, + service: "auth", stripPrefix: "/auth/v1", }), ), @@ -246,6 +339,7 @@ export class ApiProxy extends Context.Service< "/auth/v1/authorize", makeProxyHandler(client, config, { backendPort: config.gotruePort, + service: "auth", stripPrefix: "/auth/v1", }), ), @@ -254,6 +348,7 @@ export class ApiProxy extends Context.Service< "/auth/v1/*", makeProxyHandler(client, config, { backendPort: config.gotruePort, + service: "auth", stripPrefix: "/auth/v1", transformAuth: true, }), @@ -263,6 +358,7 @@ export class ApiProxy extends Context.Service< "/rest/v1/*", makeProxyHandler(client, config, { backendPort: config.postgrestPort, + service: "postgrest", stripPrefix: "/rest/v1", transformAuth: true, }), @@ -272,6 +368,7 @@ export class ApiProxy extends Context.Service< "/rest-admin/v1/*", makeProxyHandler(client, config, { backendPort: config.postgrestAdminPort, + service: "postgrest", stripPrefix: "/rest-admin/v1", }), ), @@ -280,6 +377,7 @@ export class ApiProxy extends Context.Service< "/graphql/v1", makeProxyHandler(client, config, { backendPort: config.postgrestPort, + service: "postgrest", backendPath: "/rpc/graphql", transformAuth: true, extraHeaders: { "content-profile": "graphql_public" }, @@ -290,6 +388,7 @@ export class ApiProxy extends Context.Service< "/functions/v1/*", makeProxyHandler(client, config, { backendPort: config.edgeRuntimePort, + service: "edge-runtime", stripPrefix: "/functions/v1", transformAuth: true, transformAuthCustomHeader: true, @@ -301,24 +400,30 @@ export class ApiProxy extends Context.Service< "/realtime/v1/api/*", makeProxyHandler(client, config, { backendPort: config.realtimePort, + service: "realtime", stripPrefix: "/realtime/v1", transformAuth: true, }), ), + HttpRouter.route("*", "/realtime/v1/*", makeRealtimeHandler(client, config)), HttpRouter.route( "*", - "/realtime/v1/*", + "/storage/v1/s3/*", makeProxyHandler(client, config, { - backendPort: config.realtimePort, - stripPrefix: "/realtime/v1", + backendPort: config.storagePort, + service: "storage", + stripPrefix: "/storage/v1", }), ), HttpRouter.route( "*", - "/storage/v1/s3/*", + "/storage/v1/render/image/*", makeProxyHandler(client, config, { backendPort: config.storagePort, + service: "storage", + additionalServices: config.imgproxyEnabled === true ? ["imgproxy"] : [], stripPrefix: "/storage/v1", + transformAuth: true, }), ), HttpRouter.route( @@ -326,6 +431,7 @@ export class ApiProxy extends Context.Service< "/storage/v1/*", makeProxyHandler(client, config, { backendPort: config.storagePort, + service: "storage", stripPrefix: "/storage/v1", transformAuth: true, }), @@ -335,6 +441,7 @@ export class ApiProxy extends Context.Service< "/pg/*", makeProxyHandler(client, config, { backendPort: config.pgmetaPort, + service: "pgmeta", stripPrefix: "/pg", }), ), @@ -343,6 +450,7 @@ export class ApiProxy extends Context.Service< "/analytics/v1/*", makeProxyHandler(client, config, { backendPort: config.analyticsPort, + service: "analytics", stripPrefix: "/analytics/v1", }), ), @@ -351,6 +459,7 @@ export class ApiProxy extends Context.Service< "/pooler/v2/*", makeProxyHandler(client, config, { backendPort: config.poolerPort, + service: "pooler", stripPrefix: "/pooler", }), ), @@ -359,6 +468,7 @@ export class ApiProxy extends Context.Service< "/mcp", makeProxyHandler(client, config, { backendPort: config.studioPort, + service: "studio", backendPath: "/api/mcp", }), ), diff --git a/packages/stack/src/ApiProxy.unit.test.ts b/packages/stack/src/ApiProxy.unit.test.ts index d2b3a00bbc..b2dc875b44 100644 --- a/packages/stack/src/ApiProxy.unit.test.ts +++ b/packages/stack/src/ApiProxy.unit.test.ts @@ -488,4 +488,92 @@ describe("ApiProxy", () => { await echoBody.stop(); } }); + + // --------------------------------------------------------------------------- + // Lazy service start — ensureService + // --------------------------------------------------------------------------- + + describe("ensureService", () => { + test("is invoked with the route's owning service before forwarding", async () => { + const backend = await startEchoBackend(); + const calls: string[] = []; + const config: ProxyConfig = { + ...configForPort(backend.port), + ensureService: async (name) => { + calls.push(name); + }, + }; + const proxy = await startProxy(config); + try { + await fetch(`${proxy.url}/rest/v1/users`); + await fetch(`${proxy.url}/auth/v1/token`, { headers: { apikey: PUBLISHABLE_KEY } }); + expect(calls).toEqual(["postgrest", "auth"]); + } finally { + await proxy.dispose(); + await backend.stop(); + } + }); + + test("is not invoked for /health", async () => { + const backend = await startEchoBackend(); + const calls: string[] = []; + const config: ProxyConfig = { + ...configForPort(backend.port), + ensureService: async (name) => { + calls.push(name); + }, + }; + const proxy = await startProxy(config); + try { + const res = await fetch(`${proxy.url}/health`); + expect(res.status).toBe(200); + expect(calls).toEqual([]); + } finally { + await proxy.dispose(); + await backend.stop(); + } + }); + + test("returns 502 when ensureService rejects", async () => { + const backend = await startEchoBackend(); + const config: ProxyConfig = { + ...configForPort(backend.port), + ensureService: async () => { + throw new Error("failed to start"); + }, + }; + const proxy = await startProxy(config); + try { + const res = await fetch(`${proxy.url}/rest/v1/users`); + expect(res.status).toBe(502); + expect(await res.text()).toBe("Bad gateway: failed to start postgrest"); + } finally { + await proxy.dispose(); + await backend.stop(); + } + }); + + test("starts imgproxy before transformed storage requests when enabled", async () => { + const backend = await startEchoBackend(); + const calls: string[] = []; + const config: ProxyConfig = { + ...configForPort(backend.port), + imgproxyEnabled: true, + ensureService: async (name) => { + calls.push(name); + }, + }; + const proxy = await startProxy(config); + try { + const res = await fetch(`${proxy.url}/storage/v1/render/image/public/bucket/image.png`, { + headers: { apikey: PUBLISHABLE_KEY }, + }); + expect(res.status).toBe(200); + expect(calls).toEqual(["storage", "imgproxy"]); + } finally { + await proxy.dispose(); + await backend.stop(); + } + }); + }); }); diff --git a/packages/stack/src/DaemonServer.integration.test.ts b/packages/stack/src/DaemonServer.integration.test.ts index 5e949f64f6..65016ee773 100644 --- a/packages/stack/src/DaemonServer.integration.test.ts +++ b/packages/stack/src/DaemonServer.integration.test.ts @@ -1,9 +1,10 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import { ServiceNotFoundError, type LogEntry } from "@supabase/process-compose"; +import { ServiceNotFoundError, ServiceReadyError, type LogEntry } from "@supabase/process-compose"; import { Effect, Layer, ManagedRuntime, Stream } from "effect"; import * as http from "node:http"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { DaemonServer } from "./DaemonServer.ts"; +import { StackBuildError } from "./errors.ts"; import { Stack, type StackInfo } from "./Stack.ts"; import { StackServiceState } from "./StackServiceState.ts"; @@ -45,6 +46,7 @@ const MOCK_LOGS: ReadonlyArray = [ function mockStack() { let stopped = false; + let waitAllReadyCalls = 0; const serviceCalls: string[] = []; const layer = Layer.succeed(Stack, { @@ -61,9 +63,13 @@ function mockStack() { startService: (name: string) => name === "unknown" ? Effect.fail(new ServiceNotFoundError({ name })) - : Effect.sync(() => { - serviceCalls.push(`start:${name}`); - }), + : name === "bad-build" + ? Effect.fail(new StackBuildError({ detail: "stack is stopped" })) + : name === "not-ready" + ? Effect.fail(new ServiceReadyError({ name, reason: "healthcheck failed" })) + : Effect.sync(() => { + serviceCalls.push(`start:${name}`); + }), stopService: (name: string) => name === "unknown" ? Effect.fail(new ServiceNotFoundError({ name })) @@ -76,6 +82,14 @@ function mockStack() { : Effect.sync(() => { serviceCalls.push(`restart:${name}`); }), + ensureExtensionPreload: (name: string) => + name === "unknown" + ? Effect.fail(new ServiceNotFoundError({ name })) + : name === "bad-build" + ? Effect.fail(new StackBuildError({ detail: "cannot configure preload while starting" })) + : Effect.sync(() => { + serviceCalls.push(`ensure-extension-preload:${name}`); + }), reloadFunctions: () => Effect.sync(() => { serviceCalls.push("reload-functions"); @@ -96,7 +110,10 @@ function mockStack() { allStateChanges: () => Stream.fromIterable(MOCK_STATES), waitReady: (name: string) => name === "unknown" ? Effect.fail(new ServiceNotFoundError({ name })) : Effect.void, - waitAllReady: () => Effect.void, + waitAllReady: () => + Effect.sync(() => { + waitAllReadyCalls += 1; + }), subscribeLogs: (name: string) => Stream.fromIterable(MOCK_LOGS.filter((l) => l.service === name)), subscribeAllLogs: (services?: ReadonlyArray) => @@ -121,6 +138,9 @@ function mockStack() { get stopped() { return stopped; }, + get waitAllReadyCalls() { + return waitAllReadyCalls; + }, serviceCalls, }; } @@ -207,6 +227,24 @@ describe("DaemonServer", () => { expect(text).toContain("postgres"); }); + test("GET /services/:name/ready delegates to the stack and 404s unknown services", async () => { + const ok = await fetch(`${url}/services/postgres/ready`); + expect(ok.status).toBe(200); + expect(((await ok.json()) as { ok: boolean }).ok).toBe(true); + + const missing = await fetch(`${url}/services/unknown/ready`); + expect(missing.status).toBe(404); + }); + + test("GET /ready delegates readiness to the stack", async () => { + const before = mock.waitAllReadyCalls; + const res = await fetch(`${url}/ready`); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + expect(mock.waitAllReadyCalls).toBe(before + 1); + }); + // ------------------------------------------------------------------------- // Logs // ------------------------------------------------------------------------- @@ -303,6 +341,14 @@ describe("DaemonServer", () => { expect(mock.serviceCalls).toContain("restart:postgres"); }); + test("POST /extensions/:name/preload configures the extension preload", async () => { + const res = await fetch(`${url}/extensions/pg_cron/preload`, { method: "POST" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + expect(mock.serviceCalls).toContain("ensure-extension-preload:pg_cron"); + }); + test("POST /edge-runtime/reload returns 200", async () => { const res = await fetch(`${url}/edge-runtime/reload`, { method: "POST", @@ -315,6 +361,30 @@ describe("DaemonServer", () => { expect(mock.serviceCalls).toContain("reload-edge-runtime"); }); + test("POST /extensions/:name/preload maps build errors to JSON 500", async () => { + const res = await fetch(`${url}/extensions/bad-build/preload`, { method: "POST" }); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string; service?: string }; + expect(body.error).toContain("cannot configure preload while starting"); + expect(body.service).toBeUndefined(); + }); + + test("POST /services/:name/start maps build errors to JSON 500 without a service field", async () => { + const res = await fetch(`${url}/services/bad-build/start`, { method: "POST" }); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string; service?: string }; + expect(body.error).toContain("stack is stopped"); + expect(body.service).toBeUndefined(); + }); + + test("POST /services/:name/start maps readiness errors to JSON 500 with a service field", async () => { + const res = await fetch(`${url}/services/not-ready/start`, { method: "POST" }); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string; service?: string }; + expect(body.error).toContain("healthcheck failed"); + expect(body.service).toBe("not-ready"); + }); + // ------------------------------------------------------------------------- // Error cases — service not found // ------------------------------------------------------------------------- diff --git a/packages/stack/src/DaemonServer.ts b/packages/stack/src/DaemonServer.ts index 4dca711302..ed4aa7c194 100644 --- a/packages/stack/src/DaemonServer.ts +++ b/packages/stack/src/DaemonServer.ts @@ -76,6 +76,61 @@ export class DaemonServer extends Context.Service< ), ), + // Ready: delegate readiness semantics to the stack implementation + HttpRouter.route( + "GET", + "/ready", + Effect.gen(function* () { + yield* stack.waitAllReady(); + return HttpServerResponse.jsonUnsafe({ ok: true }); + }).pipe( + Effect.catchTag("ServiceReadyError", (e) => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { error: e.reason, service: e.name }, + { status: 500 }, + ), + ), + ), + Effect.catchTag("StackBuildError", (e) => + Effect.succeed(HttpServerResponse.jsonUnsafe({ error: e.detail }, { status: 500 })), + ), + ), + ), + + // Per-service ready: delegates to the stack so lazy-service guards + // (e.g. failing fast on never-started services) apply to remote + // callers exactly as they do in-process. + HttpRouter.route( + "GET", + "/services/:name/ready", + Effect.gen(function* () { + const routeParams = yield* HttpRouter.params; + yield* stack.waitReady(routeParams.name!); + return HttpServerResponse.jsonUnsafe({ ok: true }); + }).pipe( + Effect.catchTag("ServiceNotFoundError", (e) => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { error: `Service not found: ${e.name}` }, + { status: 404 }, + ), + ), + ), + Effect.catchTag("ServiceReadyError", (e) => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { error: e.reason, service: e.name }, + { status: 500 }, + ), + ), + ), + Effect.catchTag("StackBuildError", (e) => + Effect.succeed(HttpServerResponse.jsonUnsafe({ error: e.detail }, { status: 500 })), + ), + ), + ), + // Start: begin service startup HttpRouter.route( "POST", @@ -163,8 +218,18 @@ export class DaemonServer extends Context.Service< ), ), ), + // `service` doubles as the discriminant clients use to tell + // ServiceReadyError (has it) from StackBuildError (lacks it). Effect.catchTag("ServiceReadyError", (e) => - Effect.succeed(HttpServerResponse.jsonUnsafe({ error: e.reason }, { status: 500 })), + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { error: e.reason, service: e.name }, + { status: 500 }, + ), + ), + ), + Effect.catchTag("StackBuildError", (e) => + Effect.succeed(HttpServerResponse.jsonUnsafe({ error: e.detail }, { status: 500 })), ), ), ), @@ -207,6 +272,36 @@ export class DaemonServer extends Context.Service< ), ), + HttpRouter.route( + "POST", + "/extensions/:name/preload", + Effect.gen(function* () { + const routeParams = yield* HttpRouter.params; + yield* stack.ensureExtensionPreload(routeParams.name!); + return HttpServerResponse.jsonUnsafe({ ok: true }); + }).pipe( + Effect.catchTag("ServiceNotFoundError", (e) => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { error: `Service not found: ${e.name}` }, + { status: 404 }, + ), + ), + ), + Effect.catchTag("ServiceReadyError", (e) => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { error: e.reason, service: e.name }, + { status: 500 }, + ), + ), + ), + Effect.catchTag("StackBuildError", (e) => + Effect.succeed(HttpServerResponse.jsonUnsafe({ error: e.detail }, { status: 500 })), + ), + ), + ), + HttpRouter.route( "POST", "/functions/reload", @@ -227,7 +322,15 @@ export class DaemonServer extends Context.Service< ), ), Effect.catchTag("ServiceReadyError", (e) => - Effect.succeed(HttpServerResponse.jsonUnsafe({ error: e.reason }, { status: 500 })), + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { error: e.reason, service: e.name }, + { status: 500 }, + ), + ), + ), + Effect.catchTag("StackBuildError", (e) => + Effect.succeed(HttpServerResponse.jsonUnsafe({ error: e.detail }, { status: 500 })), ), ), ), @@ -249,10 +352,15 @@ export class DaemonServer extends Context.Service< ), ), Effect.catchTag("ServiceReadyError", (e) => - Effect.succeed(HttpServerResponse.jsonUnsafe({ error: e.reason }, { status: 500 })), + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { error: e.reason, service: e.name }, + { status: 500 }, + ), + ), ), Effect.catchTag("StackBuildError", (e) => - Effect.succeed(HttpServerResponse.jsonUnsafe({ error: e.message }, { status: 500 })), + Effect.succeed(HttpServerResponse.jsonUnsafe({ error: e.detail }, { status: 500 })), ), ), ), diff --git a/packages/stack/src/PortLease.ts b/packages/stack/src/PortLease.ts new file mode 100644 index 0000000000..4623e432cd --- /dev/null +++ b/packages/stack/src/PortLease.ts @@ -0,0 +1,220 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { Effect } from "effect"; +import { + allocatePorts, + type AllocatedPorts, + PortAllocationError, + type PortInput, +} from "./PortAllocator.ts"; + +interface LeaseRecord { + readonly pid: number; + readonly ports: ReadonlyArray; +} + +interface LeaseState { + readonly leases: Readonly>; +} + +export interface PortLease { + readonly ports: AllocatedPorts; + release(): Promise; +} + +interface AcquirePortLeaseOptions { + readonly preferred?: Partial; + readonly reserved?: ReadonlySet; +} + +const LOCK_RETRY_MS = 10; + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + return typeof error.code === "string" ? error.code : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return errorCode(error) !== "ESRCH"; + } +} + +function parseState(raw: string): LeaseState { + const value: unknown = JSON.parse(raw); + if (typeof value !== "object" || value === null || !("leases" in value)) { + throw new Error("Invalid Stack port lease state"); + } + const leases = value.leases; + if (typeof leases !== "object" || leases === null || Array.isArray(leases)) { + throw new Error("Invalid Stack port lease state"); + } + const parsed: Record = {}; + for (const [id, record] of Object.entries(leases)) { + if ( + typeof record !== "object" || + record === null || + !("pid" in record) || + !("ports" in record) || + typeof record.pid !== "number" || + !Number.isInteger(record.pid) || + record.pid <= 0 || + !Array.isArray(record.ports) || + !record.ports.every( + (port: unknown) => + typeof port === "number" && Number.isInteger(port) && port > 0 && port <= 65_535, + ) + ) { + throw new Error("Invalid Stack port lease state"); + } + parsed[id] = { pid: record.pid, ports: record.ports }; + } + return { leases: parsed }; +} + +async function readState(stateFile: string): Promise { + const raw = await readFile(stateFile, "utf8").catch((error: unknown) => { + if (errorCode(error) === "ENOENT") return undefined; + throw error; + }); + return raw === undefined ? { leases: {} } : parseState(raw); +} + +async function writeState(stateFile: string, state: LeaseState): Promise { + await mkdir(dirname(stateFile), { recursive: true }); + const tempFile = `${stateFile}.tmp-${process.pid}-${randomUUID()}`; + try { + await writeFile(tempFile, JSON.stringify(state)); + await rename(tempFile, stateFile); + } catch (error) { + const cleanup = await Promise.allSettled([unlink(tempFile)]); + const cleanupErrors = cleanup.flatMap((result) => + result.status === "rejected" && errorCode(result.reason) !== "ENOENT" ? [result.reason] : [], + ); + if (cleanupErrors.length > 0) { + throw new AggregateError( + [error, ...cleanupErrors], + "Failed to persist and clean up Stack port lease state", + ); + } + throw error; + } +} + +type LockBodyResult = + | { readonly _tag: "Success"; readonly value: T } + | { readonly _tag: "Failure"; readonly error: unknown }; + +async function withStateLock(stateFile: string, body: () => Promise): Promise { + const lockFile = `${stateFile}.lock`; + await mkdir(dirname(stateFile), { recursive: true }); + for (;;) { + try { + const handle = await open(lockFile, "wx"); + let result: LockBodyResult; + try { + await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() })); + result = { _tag: "Success", value: await body() }; + } catch (error) { + result = { _tag: "Failure", error }; + } + const cleanup = [ + ...(await Promise.allSettled([handle.close()])), + ...(await Promise.allSettled([unlink(lockFile)])), + ]; + const cleanupErrors = cleanup.flatMap((outcome) => + outcome.status === "rejected" && errorCode(outcome.reason) !== "ENOENT" + ? [outcome.reason] + : [], + ); + if (result._tag === "Failure") { + if (cleanupErrors.length > 0) { + throw new AggregateError( + [result.error, ...cleanupErrors], + "Failed to use and release Stack port lease lock", + ); + } + throw result.error; + } + if (cleanupErrors.length === 1) throw cleanupErrors[0]; + if (cleanupErrors.length > 1) { + throw new AggregateError(cleanupErrors, "Failed to release Stack port lease lock"); + } + return result.value; + } catch (error) { + if (errorCode(error) !== "EEXIST") throw error; + const owner: unknown = await readFile(lockFile, "utf8") + .then((raw) => JSON.parse(raw)) + .catch(() => undefined); + const stale = + !isRecord(owner) || + typeof owner.pid !== "number" || + typeof owner.createdAt !== "number" || + !isProcessAlive(owner.pid); + if (stale) { + await unlink(lockFile).catch((error: unknown) => { + if (errorCode(error) !== "ENOENT") throw error; + }); + continue; + } + await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS)); + } + } +} + +export async function acquirePortLease( + cacheRoot: string, + input: PortInput, + options: AcquirePortLeaseOptions = {}, +): Promise { + const stateFile = join(cacheRoot, "runtime", "port-leases.json"); + const leaseId = randomUUID(); + const ports = await withStateLock(stateFile, async () => { + const state = await readState(stateFile); + const liveLeases = Object.fromEntries( + Object.entries(state.leases).filter(([, lease]) => isProcessAlive(lease.pid)), + ); + const leasedPorts = new Set(options.reserved ?? []); + for (const lease of Object.values(liveLeases)) { + for (const port of lease.ports) leasedPorts.add(port); + } + const allocated = await Effect.runPromise( + allocatePorts(input, { preferred: options.preferred, reserved: leasedPorts }), + ).catch((error: unknown) => { + if (error instanceof PortAllocationError) { + throw new Error(error.detail, { cause: error }); + } + throw error; + }); + await writeState(stateFile, { + leases: { + ...liveLeases, + [leaseId]: { pid: process.pid, ports: Object.values(allocated) }, + }, + }); + return allocated; + }); + + let released = false; + return { + ports, + async release() { + if (released) return; + await withStateLock(stateFile, async () => { + const state = await readState(stateFile); + const leases = { ...state.leases }; + delete leases[leaseId]; + await writeState(stateFile, { leases }); + }); + released = true; + }, + }; +} diff --git a/packages/stack/src/PortLease.unit.test.ts b/packages/stack/src/PortLease.unit.test.ts new file mode 100644 index 0000000000..10c9f999a8 --- /dev/null +++ b/packages/stack/src/PortLease.unit.test.ts @@ -0,0 +1,42 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { acquirePortLease } from "./PortLease.ts"; + +describe("PortLease", () => { + it("keeps concurrent same-process allocations disjoint until release", async () => { + const cacheRoot = await mkdtemp(join(tmpdir(), "stack-port-leases-")); + try { + const leases = await Promise.all( + Array.from({ length: 4 }, () => acquirePortLease(cacheRoot, {})), + ); + try { + const ports = leases.flatMap((lease) => Object.values(lease.ports)); + expect(new Set(ports).size).toBe(ports.length); + } finally { + await Promise.all(leases.map((lease) => lease.release())); + } + } finally { + await rm(cacheRoot, { recursive: true, force: true }); + } + }); + + it("protects explicit ports and makes them available after release", async () => { + const cacheRoot = await mkdtemp(join(tmpdir(), "stack-port-leases-")); + const first = await acquirePortLease(cacheRoot, {}); + const apiPort = first.ports.apiPort; + try { + await expect(acquirePortLease(cacheRoot, { apiPort })).rejects.toThrow( + `Port ${apiPort} is not available`, + ); + await first.release(); + const second = await acquirePortLease(cacheRoot, { apiPort }); + expect(second.ports.apiPort).toBe(apiPort); + await second.release(); + } finally { + await first.release(); + await rm(cacheRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/stack/src/RemoteStack.integration.test.ts b/packages/stack/src/RemoteStack.integration.test.ts index f7581f902d..6c7a61de1e 100644 --- a/packages/stack/src/RemoteStack.integration.test.ts +++ b/packages/stack/src/RemoteStack.integration.test.ts @@ -86,6 +86,12 @@ function mockStack() { : Effect.sync(() => { serviceCalls.push(`restart:${name}`); }), + ensureExtensionPreload: (name: string) => + name === "unknown" + ? Effect.fail(new ServiceNotFoundError({ name })) + : Effect.sync(() => { + serviceCalls.push(`ensure-extension-preload:${name}`); + }), reloadFunctions: () => Effect.sync(() => { serviceCalls.push("reload-functions"); @@ -228,6 +234,13 @@ describe("RemoteStack integration", () => { ); if (res.status === 404) return yield* new ServiceNotFoundError({ name }); }), + ensureExtensionPreload: (name: string) => + Effect.gen(function* () { + const res = yield* Effect.promise(() => + fetch(`${url}/extensions/${name}/preload`, { method: "POST" }), + ); + if (res.status === 404) return yield* new ServiceNotFoundError({ name }); + }), reloadFunctions: () => Effect.gen(function* () { yield* Effect.promise(() => fetch(`${url}/functions/reload`, { method: "POST" })); @@ -345,6 +358,13 @@ describe("RemoteStack integration", () => { expect(mock.serviceCalls).toContain("restart:postgres"); }); + test("ensureExtensionPreload records the call", async () => { + await clientRuntime.runPromise( + Effect.flatMap(Stack, (stack) => stack.ensureExtensionPreload("pg_cron")), + ); + expect(mock.serviceCalls).toContain("ensure-extension-preload:pg_cron"); + }); + test("reloadEdgeRuntime records the call", async () => { await clientRuntime.runPromise( Effect.flatMap(Stack, (stack) => diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts index 00803d7444..4e62e67066 100644 --- a/packages/stack/src/RemoteStack.ts +++ b/packages/stack/src/RemoteStack.ts @@ -3,6 +3,7 @@ import { Effect, Layer, Schema, Stream } from "effect"; import * as Sse from "effect/unstable/encoding/Sse"; import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { Stack, StackInfoSchema } from "./Stack.ts"; +import { StackBuildError } from "./errors.ts"; import { StackServiceState, StackServiceStatusSchema } from "./StackServiceState.ts"; import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; @@ -34,8 +35,22 @@ const StatusResponseSchema = Schema.Struct({ const ServiceErrorResponseSchema = Schema.Struct({ error: Schema.String, + service: Schema.optionalKey(Schema.String), }); +/** + * Deserialize a daemon 500 body back into the typed error the stack raised: + * the daemon includes `service` exactly when the underlying error was a + * ServiceReadyError, so its absence means a StackBuildError. + */ +function daemonServiceError( + body: typeof ServiceErrorResponseSchema.Type, +): ServiceReadyError | StackBuildError { + return body.service !== undefined + ? new ServiceReadyError({ name: body.service, reason: body.error }) + : new StackBuildError({ detail: body.error }); +} + const StatusServiceEventSchema = Schema.fromJsonString(StatusServiceSchema); const LogEntryEventSchema = Schema.fromJsonString(LogEntrySchema); const decodeStatusServiceEvent = Schema.decodeUnknownSync(StatusServiceEventSchema); @@ -246,7 +261,7 @@ export const RemoteStack = { const body = yield* HttpClientResponse.schemaBodyJson(ServiceErrorResponseSchema)( response, ).pipe(Effect.orDie); - return yield* new ServiceReadyError({ name, reason: body.error }); + return yield* daemonServiceError(body); } yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); }), @@ -278,6 +293,25 @@ export const RemoteStack = { }), ), + ensureExtensionPreload: (name: string) => + withUnixHttpClient( + Effect.gen(function* () { + const response = yield* unixResponse(socketPath, `/extensions/${name}/preload`, { + method: "POST", + }); + if (response.status === 404) { + return yield* new ServiceNotFoundError({ name }); + } + if (response.status === 500) { + const body = yield* HttpClientResponse.schemaBodyJson(ServiceErrorResponseSchema)( + response, + ).pipe(Effect.orDie); + return yield* daemonServiceError(body); + } + yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + }), + ), + reloadFunctions: (opts) => withUnixHttpClient( Effect.gen(function* () { @@ -297,10 +331,7 @@ export const RemoteStack = { const body = yield* HttpClientResponse.schemaBodyJson(ServiceErrorResponseSchema)( response, ).pipe(Effect.orDie); - return yield* new ServiceReadyError({ - name: "edge-runtime", - reason: body.error, - }); + return yield* daemonServiceError(body); } yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); }), @@ -321,10 +352,7 @@ export const RemoteStack = { const body = yield* HttpClientResponse.schemaBodyJson(ServiceErrorResponseSchema)( response, ).pipe(Effect.orDie); - return yield* new ServiceReadyError({ - name: "edge-runtime", - reason: body.error, - }); + return yield* daemonServiceError(body); } yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); }), @@ -377,60 +405,35 @@ export const RemoteStack = { waitReady: (name: string) => withUnixHttpClient( Effect.gen(function* () { - // Check current state first - const { services } = yield* fetchStatus(socketPath, "/status"); - const match = services.find((s) => s.name === name); - if (!match) { + // Delegate to the daemon so the coordinator's readiness + // semantics apply — in particular the lazy-service guard that + // fails never-started services instead of hanging forever on + // a state event that will never come. + const response = yield* unixResponse(socketPath, `/services/${name}/ready`); + if (response.status === 404) { return yield* new ServiceNotFoundError({ name }); } - if (match.status === "Healthy" || match.status === "Running") return; - - // Wait for state change via SSE - yield* withUnixHttpClient( - sseStream(socketPath, "/status/stream", (data) => { - const raw = decodeStatusServiceEvent(data); - return toServiceState(raw); - }).pipe( - Stream.filter((s) => s.name === name), - Stream.takeUntil((s) => s.status === "Healthy" || s.status === "Running"), - Stream.runDrain, - ), - ); + if (response.status === 500) { + const body = yield* HttpClientResponse.schemaBodyJson(ServiceErrorResponseSchema)( + response, + ).pipe(Effect.orDie); + return yield* daemonServiceError(body); + } + yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); }), ), waitAllReady: () => withUnixHttpClient( Effect.gen(function* () { - // Check current state first - const { services } = yield* fetchStatus(socketPath, "/status"); - const allReady = services.every( - (s) => s.status === "Healthy" || s.status === "Running", - ); - if (allReady) return; - - // Track service readiness via SSE - const readySet = new Set( - services - .filter((s) => s.status === "Healthy" || s.status === "Running") - .map((s) => s.name), - ); - const totalCount = services.length; - - yield* withUnixHttpClient( - sseStream(socketPath, "/status/stream", (data) => { - const raw = decodeStatusServiceEvent(data); - return toServiceState(raw); - }).pipe( - Stream.takeUntil((s) => { - if (s.status === "Healthy" || s.status === "Running") { - readySet.add(s.name); - } - return readySet.size >= totalCount; - }), - Stream.runDrain, - ), - ); + const response = yield* unixResponse(socketPath, "/ready"); + if (response.status === 500) { + const body = yield* HttpClientResponse.schemaBodyJson(ServiceErrorResponseSchema)( + response, + ).pipe(Effect.orDie); + return yield* daemonServiceError(body); + } + yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); }), ), diff --git a/packages/stack/src/Stack.ts b/packages/stack/src/Stack.ts index 27b53f7c14..5b845470f0 100644 --- a/packages/stack/src/Stack.ts +++ b/packages/stack/src/Stack.ts @@ -44,8 +44,12 @@ export const EdgeRuntimeReloadConfigSchema = Schema.Struct({ functions: Schema.optionalKey(FunctionsConfigSchema), }); +export interface EdgeRuntimeReloadOptions extends EdgeRuntimeConfig { + readonly enabled?: boolean; +} + export interface EdgeRuntimeReloadConfig { - readonly edgeRuntime: EdgeRuntimeConfig; + readonly edgeRuntime: EdgeRuntimeReloadOptions; readonly functions?: FunctionsConfig; } @@ -67,6 +71,9 @@ export class Stack extends Context.Service< readonly restartService: ( name: string, ) => Effect.Effect; + readonly ensureExtensionPreload: ( + name: string, + ) => Effect.Effect; readonly reloadFunctions: ( opts?: FunctionsConfig, ) => Effect.Effect; @@ -107,6 +114,7 @@ export class Stack extends Context.Service< startService: coordinator.startService, stopService: coordinator.stopService, restartService: coordinator.restartService, + ensureExtensionPreload: coordinator.ensureExtensionPreload, reloadFunctions: coordinator.reloadFunctions, reloadEdgeRuntime: coordinator.reloadEdgeRuntime, getState: coordinator.getState, diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index ea98797efe..e22df55746 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -44,6 +44,7 @@ const defaultConfig: ResolvedStackConfig = { projectDir: "/tmp/supabase-project", mode: "native", jwtSecret: testJwtSecret, + startServices: [], ports: defaultPorts, apiPort: 54321, dbPort: 54322, @@ -57,6 +58,7 @@ const defaultConfig: ResolvedStackConfig = { port: 54322, dataDir: "/tmp/supabase/data", version: DEFAULT_VERSIONS.postgres, + password: "postgres", autoExposeNewTables: true, }, postgrest: { diff --git a/packages/stack/src/StackBuilder.provisioned.unit.test.ts b/packages/stack/src/StackBuilder.provisioned.unit.test.ts new file mode 100644 index 0000000000..76cb9e1a37 --- /dev/null +++ b/packages/stack/src/StackBuilder.provisioned.unit.test.ts @@ -0,0 +1,38 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { buildServicesForTest } from "../tests/helpers/buildServices.ts"; + +describe("provisioned postgres", () => { + it("excludes postgres-init when postgres.provisioned is true", async () => { + const services = await buildServicesForTest({ postgres: { provisioned: true } }); + expect(services.map((s) => s.name)).not.toContain("postgres-init"); + }); + + it("includes postgres-init by default", async () => { + const services = await buildServicesForTest({}); + expect(services.map((s) => s.name)).toContain("postgres-init"); + }); + + it("drops -c runtime args after the micro profile config is installed", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "stack-micro-profile-")); + try { + writeFileSync(join(dataDir, "postgresql.conf"), "include_if_exists = 'micro.conf'\n"); + const services = await buildServicesForTest({ + mode: "native", + postgres: { dataDir, provisioned: true, profile: "micro" }, + }); + const pg = services.find((s) => s.name === "postgres"); + expect(pg?.args?.join(" ")).not.toContain("wal_level=logical"); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it("keeps -c runtime args on the default profile", async () => { + const services = await buildServicesForTest({}); + const pg = services.find((s) => s.name === "postgres"); + expect(pg?.args?.join(" ")).toContain("wal_level=logical"); + }); +}); diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 9975ca4fb0..3ee631cc40 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -12,6 +12,7 @@ import { dockerPortMapArgs, } from "./Platform.ts"; import type { ServiceResolution } from "./resolve.ts"; +import { validateEnabledServiceDependencies } from "./serviceDependencies.ts"; import { analyticsDockerRuntimeNetwork, makeAnalyticsServiceDocker } from "./services/analytics.ts"; import { makeAuthServiceDocker, makeAuthServiceNative } from "./services/auth.ts"; import { @@ -37,12 +38,18 @@ import { makeVectorServiceDocker } from "./services/vector.ts"; import type { PreparedStackArtifacts } from "./StackPreparation.ts"; import type { StackServiceProjectionCatalog } from "./StackStateProjection.ts"; import type { AllocatedPorts } from "./PortAllocator.ts"; -import type { ServiceName, VersionManifest } from "./versions.ts"; +import { + SERVICE_NAMES, + type ServiceName, + type StackServiceName, + type VersionManifest, +} from "./versions.ts"; export interface PostgresConfig { readonly port?: number; readonly dataDir?: string; readonly version?: string; + readonly password?: string; /** * When true (default), the bundled initial schema GRANTs that expose new tables, views, * sequences, and functions in `public` to the Data API roles (`anon`, `authenticated`, @@ -51,9 +58,22 @@ export interface PostgresConfig { * through the Data API. */ readonly autoExposeNewTables?: boolean; + /** + * When true, `dataDir` is a pre-initialized template clone (already migrated and + * configured), so the `postgres-init` service is skipped entirely. + */ + readonly provisioned?: boolean; + /** + * "micro": settings live in `micro.conf`/`pod.conf` inside PGDATA rather than being passed as + * `-c` runtime args, so ALTER SYSTEM changes made by users aren't overridden on every boot. + * Absent or "default": current behavior (settings passed via `-c` args) is unchanged. + */ + readonly profile?: "default" | "micro"; } export interface PostgrestConfig { + readonly port?: number; + readonly adminPort?: number; readonly schemas?: ReadonlyArray; readonly extraSearchPath?: ReadonlyArray; readonly maxRows?: number; @@ -78,7 +98,6 @@ export interface RealtimeConfig { } export interface EdgeRuntimeConfig { - readonly enabled?: boolean; readonly port?: number; readonly inspectorPort?: number; readonly policy?: "oneshot" | "per_worker"; @@ -148,31 +167,38 @@ export interface StackConfig { readonly runtimeRoot?: string; readonly projectDir?: string; readonly mode?: "native" | "auto" | "docker"; + /** Sidecars available in this stack. Postgres is always enabled. */ + readonly services?: ReadonlyArray; + /** Selected sidecars to start before createStack returns. Default: none. */ + readonly startServices?: ReadonlyArray; readonly jwtSecret?: string; readonly port?: number; readonly publishableKey?: string; readonly secretKey?: string; readonly functions?: FunctionsConfig | false; readonly postgres?: PostgresConfig; - readonly postgrest?: PostgrestConfig | false; - readonly auth?: AuthConfig | false; - readonly edgeRuntime?: EdgeRuntimeConfig | false; - readonly realtime?: RealtimeConfig | false; - readonly storage?: StorageConfig | false; - readonly imgproxy?: ImgproxyConfig | false; - readonly mailpit?: MailpitConfig | false; - readonly pgmeta?: PgmetaConfig | false; - readonly studio?: StudioConfig | false; - readonly analytics?: AnalyticsConfig | false; - readonly vector?: VectorConfig | false; - readonly pooler?: PoolerConfig | false; + readonly postgrest?: PostgrestConfig; + readonly auth?: AuthConfig; + readonly edgeRuntime?: EdgeRuntimeConfig; + readonly realtime?: RealtimeConfig; + readonly storage?: StorageConfig; + readonly imgproxy?: ImgproxyConfig; + readonly mailpit?: MailpitConfig; + readonly pgmeta?: PgmetaConfig; + readonly studio?: StudioConfig; + readonly analytics?: AnalyticsConfig; + readonly vector?: VectorConfig; + readonly pooler?: PoolerConfig; } export interface ResolvedPostgresConfig { readonly port: number; readonly dataDir: string; readonly version: string; + readonly password: string; readonly autoExposeNewTables: boolean; + readonly provisioned?: boolean; + readonly profile?: "default" | "micro"; } export interface ResolvedPostgrestConfig { @@ -273,6 +299,7 @@ export interface ResolvedStackConfig { readonly projectDir: string; readonly mode: "native" | "auto" | "docker"; readonly jwtSecret: string; + readonly startServices: ReadonlyArray; readonly ports: AllocatedPorts; readonly apiPort: number; readonly dbPort: number; @@ -360,10 +387,25 @@ const resolvedConfigForService = ( service: Exclude, ) => (service === "edge-runtime" ? config.edgeRuntime : config[service]); +const nonPostgresServices = SERVICE_NAMES.filter( + (service): service is Exclude => service !== "postgres", +); + export const validateResolvedConfig = ( config: ResolvedStackConfig, ): Effect.Effect => Effect.gen(function* () { + // The micro conf overlay (micro.conf/pod.conf includes) is only installed + // when template data dirs are built; on a fresh data dir the profile would + // silently fall back to default settings, so reject it up front. + if (config.postgres.profile === "micro" && config.postgres.provisioned !== true) { + return yield* Effect.fail( + new StackBuildError({ + detail: + 'postgres.profile "micro" requires a provisioned data dir cloned from a template with the micro conf overlay installed; a fresh data dir would silently run with default settings.', + }), + ); + } if (config.mode === "native") { const enabledDockerOnly = dockerOnlyServices.filter( (service) => resolvedConfigForService(config, service) !== false, @@ -377,26 +419,17 @@ export const validateResolvedConfig = ( } } - if (config.imgproxy !== false && config.storage === false) { - return yield* Effect.fail( - new StackBuildError({ - detail: "imgproxy requires storage to be enabled", - }), - ); - } - - if (config.vector !== false && config.analytics === false) { - return yield* Effect.fail( - new StackBuildError({ - detail: "vector requires analytics to be enabled", - }), - ); + const enabledServices = new Set(["postgres"]); + for (const service of nonPostgresServices) { + if (resolvedConfigForService(config, service) !== false) { + enabledServices.add(service); + } } - - if (config.studio !== false && config.pgmeta === false) { + const dependencyError = validateEnabledServiceDependencies(enabledServices); + if (dependencyError !== undefined) { return yield* Effect.fail( new StackBuildError({ - detail: "studio requires pgmeta to be enabled", + detail: dependencyError, }), ); } @@ -504,30 +537,49 @@ export class StackBuilder extends Context.Service< readonly build: ( config: ResolvedStackConfig, prepared: PreparedStackArtifacts, + services?: ReadonlyArray, ) => Effect.Effect; } >()("local/StackBuilder") { static layer: Layer.Layer = Layer.succeed(this, { - build: (config: ResolvedStackConfig, prepared: PreparedStackArtifacts) => + build: ( + config: ResolvedStackConfig, + prepared: PreparedStackArtifacts, + services?: ReadonlyArray, + ) => Effect.gen(function* () { yield* validateResolvedConfig(config); + const includedServices = new Set(services ?? enabledServicesForConfig(config)); + includedServices.add("postgres"); + const includes = (service: ServiceName): boolean => includedServices.has(service); + const platform = yield* detectPlatform; const serviceHost = dockerHostAddress(platform.os); const projectDir = config.projectDir; const postgresResolution = yield* requirePreparedResolution(prepared, "postgres"); + if (config.postgres.profile === "micro" && postgresResolution.type !== "binary") { + return yield* Effect.fail( + new StackBuildError({ + detail: + 'postgres.profile "micro" requires a native Postgres binary; automatic resolution selected Docker instead.', + }), + ); + } const authResolution = - config.auth === false ? false : yield* requirePreparedResolution(prepared, "auth"); + config.auth === false || !includes("auth") + ? false + : yield* requirePreparedResolution(prepared, "auth"); const edgeRuntimeResolution = - config.edgeRuntime === false + config.edgeRuntime === false || !includes("edge-runtime") ? false : yield* requirePreparedResolution(prepared, "edge-runtime"); const postgrestResolution = - config.postgrest === false + config.postgrest === false || !includes("postgrest") ? false : yield* requirePreparedResolution(prepared, "postgrest"); @@ -549,10 +601,15 @@ export class StackBuilder extends Context.Service< postgresResolution, dockerServicesEnabled, ); - const hasPostgresInit = postgresResolution.type === "binary"; + const hasPostgresInit = + postgresResolution.type === "binary" && config.postgres.provisioned !== true; const postgresDeps = dependsOnPostgres(hasPostgresInit); const jwtJwks = generateJwks(config.jwtSecret); + // A build may contain only the definitions prepared so far. The lifecycle coordinator + // adds sidecar definitions to the running orchestrator after their assets are prepared + // on first use. + const defs: Array = [ { ...(postgresResolution.type === "binary" @@ -560,13 +617,16 @@ export class StackBuilder extends Context.Service< binPath: postgresResolution.path, dataDir: config.postgres.dataDir, port: config.dbPort, + password: config.postgres.password, dockerAccessible: needsDockerAccess, cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), + profile: config.postgres.profile, }) : makePostgresServiceDocker({ image: postgresResolution.image, dataDir: config.postgres.dataDir, port: config.dbPort, + password: config.postgres.password, networkArgs: dockerNetworkArgs(platform.os, [config.dbPort]), jwtSecret: config.jwtSecret, jwtExpiry: config.auth !== false ? config.auth.jwtExpiry : 3600, @@ -582,18 +642,20 @@ export class StackBuilder extends Context.Service< ...makePostgresInitService({ postgresDir: postgresResolution.path, dbPort: config.dbPort, + dbPassword: config.postgres.password, autoExposeNewTables: config.postgres.autoExposeNewTables, }), enabled: true, }); } - if (config.postgrest !== false && postgrestResolution !== false) { + if (config.postgrest !== false && postgrestResolution !== false && includes("postgrest")) { defs.push({ ...(postgrestResolution.type === "binary" ? makePostgrestService({ binPath: postgrestResolution.path, dbPort: config.dbPort, + dbPassword: config.postgres.password, port: config.postgrest.port, schemas: config.postgrest.schemas, extraSearchPath: config.postgrest.extraSearchPath, @@ -604,6 +666,7 @@ export class StackBuilder extends Context.Service< image: postgrestResolution.image, dbHost: serviceHost, dbPort: config.dbPort, + dbPassword: config.postgres.password, port: config.postgrest.port, adminPort: config.postgrest.adminPort, schemas: config.postgrest.schemas, @@ -625,12 +688,13 @@ export class StackBuilder extends Context.Service< }); } - if (config.auth !== false && authResolution !== false) { + if (config.auth !== false && authResolution !== false && includes("auth")) { defs.push({ ...(authResolution.type === "binary" ? makeAuthServiceNative({ binPath: authResolution.path, dbPort: config.dbPort, + dbPassword: config.postgres.password, authPort: config.auth.port, siteUrl: config.auth.siteUrl, jwtSecret: config.jwtSecret, @@ -646,6 +710,7 @@ export class StackBuilder extends Context.Service< image: authResolution.image, dbHost: serviceHost, dbPort: config.dbPort, + dbPassword: config.postgres.password, authPort: config.auth.port, siteUrl: config.auth.siteUrl, jwtSecret: config.jwtSecret, @@ -663,7 +728,11 @@ export class StackBuilder extends Context.Service< }); } - if (config.edgeRuntime !== false && edgeRuntimeResolution !== false) { + if ( + config.edgeRuntime !== false && + edgeRuntimeResolution !== false && + includes("edge-runtime") + ) { defs.push({ ...(edgeRuntimeResolution.type === "binary" ? makeEdgeRuntimeServiceNative({ @@ -691,7 +760,7 @@ export class StackBuilder extends Context.Service< }); } - if (config.mailpit !== false) { + if (config.mailpit !== false && includes("mailpit")) { const mailpitImage = yield* requirePreparedDockerImage(prepared, "mailpit"); defs.push({ ...makeMailpitServiceDocker({ @@ -710,7 +779,7 @@ export class StackBuilder extends Context.Service< }); } - if (config.realtime !== false) { + if (config.realtime !== false && includes("realtime")) { const realtimeImage = yield* requirePreparedDockerImage(prepared, "realtime"); defs.push({ ...makeRealtimeServiceDocker({ @@ -719,6 +788,7 @@ export class StackBuilder extends Context.Service< apiPort: config.apiPort, dbHost: serviceHost, dbPort: config.dbPort, + dbPassword: config.postgres.password, jwtSecret: config.jwtSecret, jwtJwks, tenantId: config.realtime.tenantId, @@ -732,7 +802,7 @@ export class StackBuilder extends Context.Service< }); } - if (config.storage !== false) { + if (config.storage !== false && includes("storage")) { const storageImage = yield* requirePreparedDockerImage(prepared, "storage"); defs.push({ ...makeStorageServiceDocker({ @@ -741,6 +811,7 @@ export class StackBuilder extends Context.Service< apiPort: config.apiPort, dbHost: serviceHost, dbPort: config.dbPort, + dbPassword: config.postgres.password, dataDir: config.storage.dataDir, anonKey: config.publishableKey, serviceKey: config.secretKey, @@ -759,7 +830,7 @@ export class StackBuilder extends Context.Service< }); } - if (config.imgproxy !== false) { + if (config.imgproxy !== false && includes("imgproxy")) { const storageConfig = config.storage; const imgproxyImage = yield* requirePreparedDockerImage(prepared, "imgproxy"); defs.push({ @@ -775,7 +846,7 @@ export class StackBuilder extends Context.Service< }); } - if (config.pgmeta !== false) { + if (config.pgmeta !== false && includes("pgmeta")) { const pgmetaImage = yield* requirePreparedDockerImage(prepared, "pgmeta"); defs.push({ ...makePgmetaServiceDocker({ @@ -784,6 +855,7 @@ export class StackBuilder extends Context.Service< port: config.pgmeta.port, dbHost: serviceHost, dbPort: config.dbPort, + dbPassword: config.postgres.password, networkArgs: dockerNetworkArgs(platform.os, [config.pgmeta.port]), dependencies: postgresDeps, }), @@ -791,7 +863,7 @@ export class StackBuilder extends Context.Service< }); } - if (config.analytics !== false) { + if (config.analytics !== false && includes("analytics")) { const analyticsImage = yield* requirePreparedDockerImage(prepared, "analytics"); const analyticsRuntimeNetwork = analyticsDockerRuntimeNetwork( platform.os, @@ -807,6 +879,7 @@ export class StackBuilder extends Context.Service< nodeHost: analyticsRuntimeNetwork.nodeHost, dbHost: serviceHost, dbPort: config.dbPort, + dbPassword: config.postgres.password, apiKey: config.analytics.apiKey, backend: config.analytics.backend, networkArgs: dockerPortMapArgs(platform.os, [ @@ -818,7 +891,7 @@ export class StackBuilder extends Context.Service< }); } - if (config.vector !== false) { + if (config.vector !== false && includes("vector")) { const analyticsConfig = config.analytics; const vectorImage = yield* requirePreparedDockerImage(prepared, "vector"); defs.push({ @@ -835,7 +908,7 @@ export class StackBuilder extends Context.Service< }); } - if (config.pooler !== false) { + if (config.pooler !== false && includes("pooler")) { const poolerImage = yield* requirePreparedDockerImage(prepared, "pooler"); defs.push({ ...makePoolerServiceDocker({ @@ -844,6 +917,7 @@ export class StackBuilder extends Context.Service< hostAdminPort: config.pooler.apiPort, dbHost: serviceHost, dbPort: config.dbPort, + dbPassword: config.postgres.password, poolMode: config.pooler.mode, defaultPoolSize: config.pooler.defaultPoolSize, maxClientConn: config.pooler.maxClientConn, @@ -870,7 +944,7 @@ export class StackBuilder extends Context.Service< }); } - if (config.studio !== false) { + if (config.studio !== false && includes("studio")) { const pgmetaConfig = config.pgmeta; const studioImage = yield* requirePreparedDockerImage(prepared, "studio"); defs.push({ @@ -881,6 +955,7 @@ export class StackBuilder extends Context.Service< apiUrl: config.studio.apiUrl, publicApiUrl: `http://127.0.0.1:${config.apiPort}`, pgmetaUrl: pgmetaConfig === false ? "" : `http://${serviceHost}:${pgmetaConfig.port}`, + dbPassword: config.postgres.password, publishableKey: config.publishableKey, secretKey: config.secretKey, s3ProtocolAccessKeyId: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index 261ccd277c..ee9ceabd4d 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Layer, Sink, Stream } from "effect"; +import { Deferred, Effect, Exit, Layer, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; import { defaultPublishableKey, defaultSecretKey, generateJwt } from "./JwtGenerator.ts"; import { StackBuilder } from "./StackBuilder.ts"; import type { BuildResult } from "./StackBuilder.ts"; import type { ResolvedStackConfig } from "./StackBuilder.ts"; -import { enabledServicesForConfig, versionsForConfig } from "./StackBuilder.ts"; +import { + enabledServicesForConfig, + validateResolvedConfig, + versionsForConfig, +} from "./StackBuilder.ts"; import { nativePostgresNeedsDockerAccess } from "./StackBuilder.ts"; import type { AllocatedPorts } from "./PortAllocator.ts"; import { StackPreparation } from "./StackPreparation.ts"; @@ -43,6 +47,7 @@ const baseConfig: ResolvedStackConfig = { projectDir: "/tmp/supabase-project", mode: "auto", jwtSecret: testJwtSecret, + startServices: [], ports: basePorts, apiPort: 3000, dbPort: 5432, @@ -56,6 +61,7 @@ const baseConfig: ResolvedStackConfig = { port: 5432, dataDir: "/tmp/pg-data", version: DEFAULT_VERSIONS.postgres, + password: "postgres", autoExposeNewTables: true, }, postgrest: { @@ -298,6 +304,29 @@ describe("StackBuilder", () => { }).pipe(Effect.provide(layer)); }); + it.effect("builds only the requested service definitions", () => { + const resolver = mockBinaryResolver(); + const layer = builderLayer(resolver); + + return Effect.gen(function* () { + const builder = yield* StackBuilder; + const preparation = yield* StackPreparation; + const config = { ...baseConfig, auth: baseConfig.auth }; + const prepared = yield* preparation.prepare({ + mode: config.mode, + services: enabledServicesForConfig(config), + versions: versionsForConfig(config), + }); + const { graph } = yield* builder.build(config, prepared, ["postgres"]); + + const byName = new Map(graph.startOrder.map((def) => [def.name, def] as const)); + expect(byName.get("postgres")?.enabled).toBe(true); + expect(byName.get("postgres-init")?.enabled).toBe(true); + expect(byName.has("postgrest")).toBe(false); + expect(byName.has("auth")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + it.effect("docker mode produces Docker service defs for all services", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); @@ -462,4 +491,30 @@ describe("StackBuilder", () => { ); }).pipe(Effect.provide(layer)); }); + + it.effect("rejects the micro profile on non-provisioned data dirs", () => + Effect.gen(function* () { + const nonProvisioned = yield* validateResolvedConfig({ + ...baseConfig, + mode: "native", + postgres: { ...baseConfig.postgres, profile: "micro" }, + }).pipe(Effect.exit); + expect(Exit.isFailure(nonProvisioned)).toBe(true); + + // Auto mode is valid here because preparation verifies that Postgres + // resolved to a native binary before building the graph. + yield* validateResolvedConfig({ + ...baseConfig, + postgres: { ...baseConfig.postgres, profile: "micro", provisioned: true }, + }); + + yield* validateResolvedConfig({ + ...baseConfig, + mode: "native", + postgrest: false, + auth: false, + postgres: { ...baseConfig.postgres, profile: "micro", provisioned: true }, + }); + }), + ); }); diff --git a/packages/stack/src/StackLifecycleCoordinator.ts b/packages/stack/src/StackLifecycleCoordinator.ts index 0a8ba5bee1..d6e53080ca 100644 --- a/packages/stack/src/StackLifecycleCoordinator.ts +++ b/packages/stack/src/StackLifecycleCoordinator.ts @@ -1,6 +1,6 @@ import { LogBuffer, Orchestrator } from "@supabase/process-compose"; import { ServiceNotFoundError } from "@supabase/process-compose"; -import type { LogEntry, ServiceReadyError } from "@supabase/process-compose"; +import type { LogEntry, ResolvedGraph, ServiceReadyError } from "@supabase/process-compose"; import { Deferred, Effect, @@ -9,15 +9,18 @@ import { Path, Ref, Context, + Semaphore, Stream, SubscriptionRef, } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import type { CleanupTargets } from "./CleanupTargets.ts"; import { cleanupLocalStackResources } from "./cleanup.ts"; +import { configureExtensionPreload } from "./extensionPreload.ts"; import { StackBuildError } from "./errors.ts"; import { configureFunctionsRuntime, type FunctionsConfig } from "./functions.ts"; import { detectPlatform, dockerHostAddress } from "./Platform.ts"; +import { postgresConnectionUrl } from "./postgresCredentials.ts"; import { StackMetadataPersistence } from "./StackMetadataPersistence.ts"; import { StackPreparation } from "./StackPreparation.ts"; import type { PreparedStackArtifacts } from "./StackPreparation.ts"; @@ -31,6 +34,7 @@ import { import { changedProjectedStates, projectStackStates } from "./StackStateProjection.ts"; import { StackServiceState } from "./StackServiceState.ts"; import type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts"; +import type { ServiceName } from "./versions.ts"; type LifecyclePhase = | "idle" @@ -43,9 +47,60 @@ type LifecyclePhase = interface RuntimeState { readonly orchestrator: Orchestrator["Service"]; - readonly cleanupTargets: CleanupTargets; + cleanupTargets: CleanupTargets; + graph: ResolvedGraph; } +const serviceDependencies: Partial>> = { + imgproxy: ["storage"], + vector: ["analytics"], + studio: ["pgmeta"], +}; + +const dependencyClosure = (services: ReadonlyArray): ReadonlyArray => { + const included = new Set(["postgres"]); + const visit = (service: ServiceName): void => { + if (included.has(service)) return; + for (const dependency of serviceDependencies[service] ?? []) visit(dependency); + included.add(service); + }; + for (const service of services) visit(service); + return [...included]; +}; + +// postgres/postgres-init are always installed first, so ServiceNotFoundError cannot occur when +// the coordinator starts them. Map it to StackBuildError only to preserve the lifecycle error +// boundary if the graph invariant is ever broken. +const eagerStartService = ( + orchestrator: Orchestrator["Service"], + name: string, +): Effect.Effect => + orchestrator.startService(name).pipe( + Effect.catchTag( + "ServiceNotFoundError", + (error) => + new StackBuildError({ + detail: `eager start: unexpected missing service "${error.name}"`, + }), + ), + ); + +// Names come from installed graph definitions or from a service already validated by the +// coordinator. Map an impossible ServiceNotFoundError to StackBuildError at this boundary. +const waitReadyKnownService = ( + orchestrator: Orchestrator["Service"], + name: string, +): Effect.Effect => + orchestrator.waitReady(name).pipe( + Effect.catchTag( + "ServiceNotFoundError", + (error) => + new StackBuildError({ + detail: `waitReady: unexpected missing service "${error.name}"`, + }), + ), + ); + const sameState = (a: StackServiceState | undefined, b: StackServiceState): boolean => a?.name === b.name && a.status === b.status && @@ -71,7 +126,13 @@ const initialPublicStates = (config: ResolvedStackConfig): ReadonlyArray ({ url: `http://127.0.0.1:${config.apiPort}`, - dbUrl: `postgresql://postgres:postgres@127.0.0.1:${config.dbPort}/postgres`, + dbUrl: postgresConnectionUrl({ + user: "postgres", + password: config.postgres.password, + host: "127.0.0.1", + port: config.dbPort, + database: "postgres", + }), publishableKey: config.publishableKey, secretKey: config.secretKey, anonJwt: config.anonJwt, @@ -110,7 +171,13 @@ const stackInfoFor = (config: ResolvedStackConfig): StackInfo => ({ ...(config.pooler === false ? {} : { - pooler: `postgresql://postgres:postgres@127.0.0.1:${config.pooler.port}/postgres`, + pooler: postgresConnectionUrl({ + user: "postgres", + password: config.postgres.password, + host: "127.0.0.1", + port: config.pooler.port, + database: "postgres", + }), pooler_admin: `http://127.0.0.1:${config.pooler.apiPort}`, }), }, @@ -145,6 +212,9 @@ export class StackLifecycleCoordinator extends Context.Service< readonly restartService: ( name: string, ) => Effect.Effect; + readonly ensureExtensionPreload: ( + name: string, + ) => Effect.Effect; readonly reloadFunctions: ( opts?: FunctionsConfig, ) => Effect.Effect; @@ -196,6 +266,21 @@ export class StackLifecycleCoordinator extends Context.Service< const info = stackInfoFor(config); const stateRef = yield* SubscriptionRef.make(initialPublicStates(config)); const phaseRef = yield* Ref.make("idle"); + const startInFlightRef = yield* Ref.make(false); + const ensureExtensionPreloadLock = yield* Semaphore.make(1); + const disposeLock = yield* Semaphore.make(1); + // Tracks services that have actually been asked to start. waitAllReady() only waits on + // this set because an available but never-requested sidecar intentionally has no running + // process and therefore cannot resolve an orchestrator readiness signal. + const startedServicesRef = yield* Ref.make>(new Set()); + const markStarted = (names: Iterable) => + Ref.update(startedServicesRef, (current) => new Set([...current, ...names])); + const markStopped = (names: Iterable) => + Ref.update(startedServicesRef, (current) => { + const next = new Set(current); + for (const name of names) next.delete(name); + return next; + }); const logBufferServices = yield* Layer.buildWithScope(LogBuffer.layer, scope); const logBuffer = Context.get(logBufferServices, LogBuffer); @@ -220,108 +305,114 @@ export class StackLifecycleCoordinator extends Context.Service< } return match; }); + const requireRunningForServiceStart = (name: string) => + Effect.gen(function* () { + const phase = yield* Ref.get(phaseRef); + if (phase !== "running") { + return yield* Effect.fail( + new StackBuildError({ + detail: `Cannot start service "${name}" while the stack is ${phase}. Call start() first and retry after it finishes.`, + }), + ); + } + }); - let preparedArtifacts: PreparedStackArtifacts | undefined; - let prepareDeferred: Deferred.Deferred | undefined; + let preparedArtifacts: PreparedStackArtifacts = { resolutions: {} }; + const preparationLock = yield* Semaphore.make(1); let runtimeState: RuntimeState | undefined; let runtimeDeferred: Deferred.Deferred | undefined; - const ensurePrepared = Effect.suspend(() => { - if (preparedArtifacts !== undefined) { - return Effect.succeed(preparedArtifacts); - } - if (prepareDeferred !== undefined) { - return Deferred.await(prepareDeferred); - } + const ensureServicesPrepared = (requested: ReadonlyArray) => + preparationLock.withPermits(1)( + Effect.gen(function* () { + const configured = new Set(enabledServicesForConfig(config)); + const missing = [...new Set(requested)].filter( + (service) => + configured.has(service) && preparedArtifacts.resolutions[service] === undefined, + ); + if (missing.length === 0) return preparedArtifacts; + + const priorPhase = yield* Ref.get(phaseRef); + const initialPreparation = priorPhase === "idle"; + if (initialPreparation) yield* Ref.set(phaseRef, "preparing"); + yield* validateResolvedConfig(config); + + let prepared: PreparedStackArtifacts | undefined; + yield* preparation + .prepareEvents({ + mode: config.mode, + services: missing, + versions: versionsForConfig(config), + }) + .pipe( + Stream.mapError( + (cause) => + new StackBuildError({ + detail: `Failed to prepare stack assets: ${missing.join(", ")}`, + cause, + }), + ), + 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; + }); + } + }), + ); - const deferred = Deferred.makeUnsafe(); - prepareDeferred = deferred; + if (prepared === undefined) { + return yield* Effect.fail( + new StackBuildError({ + detail: "Stack preparation completed without prepared artifacts", + }), + ); + } - 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( - (cause) => - new StackBuildError({ - detail: "Failed to prepare stack assets", - cause, - }), - ), - ) - .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; - }); + preparedArtifacts = { + resolutions: { + ...preparedArtifacts.resolutions, + ...prepared.resolutions, + }, + }; + if (initialPreparation) yield* Ref.set(phaseRef, "prepared"); + return preparedArtifacts; + }).pipe( + Effect.onError(() => + Effect.gen(function* () { + if ((yield* Ref.get(phaseRef)) === "preparing") { + yield* Ref.set(phaseRef, "idle"); } }), - ); - - if (prepared === undefined) { - return yield* Effect.fail( - new StackBuildError({ - detail: "Stack preparation completed without prepared artifacts", - }), - ); - } - - 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 ensureRuntime = Effect.suspend(() => { if (runtimeState !== undefined) { return Effect.succeed(runtimeState); @@ -334,10 +425,11 @@ export class StackLifecycleCoordinator extends Context.Service< runtimeDeferred = deferred; const effect = Effect.gen(function* () { - const prepared = yield* ensurePrepared; + const prepared = yield* ensureServicesPrepared(["postgres"]); const { graph, serviceProjection, cleanupTargets } = yield* builder.build( config, prepared, + ["postgres"], ); yield* metadataPersistence.persistCleanupTargets(cleanupTargets).pipe( @@ -398,6 +490,7 @@ export class StackLifecycleCoordinator extends Context.Service< return { orchestrator, cleanupTargets, + graph, } satisfies RuntimeState; }).pipe( Effect.tap((value) => @@ -418,11 +511,54 @@ export class StackLifecycleCoordinator extends Context.Service< }); }); + const activationLock = yield* Semaphore.make(1); + const activateServices = (runtime: RuntimeState, requested: ReadonlyArray) => + activationLock.withPermits(1)( + Effect.gen(function* () { + const configured = new Set(enabledServicesForConfig(config)); + const normalized = enabledServicesForConfig(config).filter((service) => + requested.includes(service), + ); + const closure = dependencyClosure(normalized).filter((service) => + configured.has(service), + ); + yield* ensureServicesPrepared(closure); + + const preparedServices = enabledServicesForConfig(config).filter( + (service) => preparedArtifacts.resolutions[service] !== undefined, + ); + const buildResult = yield* builder.build(config, preparedArtifacts, preparedServices); + const installed = new Set(runtime.graph.startOrder.map((def) => def.name)); + const additions = buildResult.graph.startOrder.filter( + (def) => !installed.has(def.name), + ); + yield* runtime.orchestrator.addServiceDefinitions(additions).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to install prepared service definitions", + cause, + }), + ), + ); + runtime.graph = buildResult.graph; + runtime.cleanupTargets = buildResult.cleanupTargets; + yield* metadataPersistence.persistCleanupTargets(runtime.cleanupTargets).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: "Failed to persist stack cleanup metadata", + cause, + }), + ), + ); + }), + ); + let disposed = false; const runtimeHost = Effect.gen(function* () { - const prepared = yield* ensurePrepared; const platform = yield* detectPlatform; - const edgeRuntimeResolution = prepared.resolutions["edge-runtime"]; + const edgeRuntimeResolution = preparedArtifacts.resolutions["edge-runtime"]; return { hostname: edgeRuntimeResolution?.type === "docker" @@ -477,7 +613,6 @@ export class StackLifecycleCoordinator extends Context.Service< ...base, edgeRuntime: { ...config.edgeRuntime, - enabled: opts.edgeRuntime.enabled ?? config.edgeRuntime.enabled, inspectorPort: opts.edgeRuntime.inspectorPort ?? config.edgeRuntime.inspectorPort, policy: opts.edgeRuntime.policy ?? config.edgeRuntime.policy, env: opts.edgeRuntime.env ?? config.edgeRuntime.env, @@ -496,18 +631,22 @@ export class StackLifecycleCoordinator extends Context.Service< ), ); const disposeOnce = () => - Effect.gen(function* () { - if (disposed) { - return; - } - disposed = true; - yield* cleanupLocalStackResources({ - stop: () => - runtimeState === undefined ? Effect.void : runtimeState.orchestrator.stop(), - cleanupTargets: runtimeState?.cleanupTargets ?? { dockerContainerNames: [] }, - config, - }); - }); + disposeLock.withPermits(1)( + Effect.gen(function* () { + if (disposed) { + return; + } + yield* cleanupLocalStackResources({ + stop: () => + runtimeState === undefined ? Effect.void : runtimeState.orchestrator.stop(), + cleanupTargets: runtimeState?.cleanupTargets ?? { dockerContainerNames: [] }, + config, + }); + // Only certify disposal after every cleanup stage succeeds. A + // failed attempt remains retryable through StackHandle.dispose(). + disposed = true; + }), + ); yield* Effect.addFinalizer(disposeOnce); @@ -517,13 +656,37 @@ export class StackLifecycleCoordinator extends Context.Service< Effect.succeed(runtimeState?.cleanupTargets ?? { dockerContainerNames: [] }), start: () => Effect.gen(function* () { + yield* Ref.set(startInFlightRef, true); yield* Ref.set(phaseRef, "starting"); const runtime = yield* ensureRuntime; - yield* configureFunctions(config); - yield* runtime.orchestrator.start(); - yield* runtime.orchestrator.waitAllReady(); + if (config.startServices.length > 0) { + yield* activateServices(runtime, config.startServices); + } + yield* Ref.set(phaseRef, "starting"); + if (config.startServices.includes("edge-runtime")) { + yield* configureFunctions(config); + } + const eagerNames = new Set(["postgres", "postgres-init"]); + for (const service of config.startServices) { + for (const def of runtime.graph.startOrderFor(service)) eagerNames.add(def.name); + } + const eagerServices = runtime.graph.startOrder + .map((def) => def.name) + .filter((name) => eagerNames.has(name)); + for (const name of eagerServices) { + yield* eagerStartService(runtime.orchestrator, name); + } + yield* markStarted(eagerServices); + yield* Effect.forEach( + eagerServices, + (name) => waitReadyKnownService(runtime.orchestrator, name), + { concurrency: "unbounded" }, + ); yield* Ref.set(phaseRef, "running"); - }), + }).pipe( + Effect.onError(() => Ref.set(phaseRef, "stopped")), + Effect.ensuring(Ref.set(startInFlightRef, false)), + ), stop: () => Effect.gen(function* () { if (runtimeState === undefined) { @@ -532,14 +695,23 @@ export class StackLifecycleCoordinator extends Context.Service< } yield* Ref.set(phaseRef, "stopping"); yield* runtimeState.orchestrator.stop(); + yield* Ref.set(startedServicesRef, new Set()); yield* Ref.set(phaseRef, "stopped"); }), dispose: disposeOnce, startService: (name) => Effect.gen(function* () { yield* requireKnownService(name); + yield* requireRunningForServiceStart(name); const runtime = yield* ensureRuntime; + yield* activateServices(runtime, [name]); + if (name === "edge-runtime") yield* configureFunctions(config); yield* runtime.orchestrator.startService(name); + // The orchestrator starts the full dependency closure of `name`, + // so the started set must record every service it brought up — + // otherwise waitAllReady()'s lazy semantics would ignore a + // failing dependency (e.g. pgmeta started implicitly by studio). + yield* markStarted(runtime.graph.startOrderFor(name).map((def) => def.name)); yield* runtime.orchestrator.waitReady(name); }), stopService: (name) => @@ -547,28 +719,95 @@ export class StackLifecycleCoordinator extends Context.Service< yield* requireKnownService(name); const runtime = yield* ensureRuntime; yield* runtime.orchestrator.stopService(name); + yield* markStopped([name]); }), restartService: (name) => Effect.gen(function* () { yield* requireKnownService(name); + // Restart only restarts the target and its dependents, never the + // dependency closure — a never-started lazy service would come up + // waiting on dependencies that stay Pending forever. Reject it, + // mirroring the waitReady guard; callers want startService here. + const startedServices = yield* Ref.get(startedServicesRef); + if (!startedServices.has(name)) { + return yield* Effect.fail( + new StackBuildError({ + detail: `Cannot restart service "${name}": it has not been started yet.`, + }), + ); + } const runtime = yield* ensureRuntime; yield* runtime.orchestrator.restartService(name); + // Idempotent: the service was necessarily already in the started set (you can't + // restart something that was never started), but marking again is harmless and + // keeps this call site self-contained if that invariant ever changes. + yield* markStarted([name]); }), + ensureExtensionPreload: (name) => + ensureExtensionPreloadLock.withPermits(1)( + Effect.gen(function* () { + const startInFlight = yield* Ref.get(startInFlightRef); + if (startInFlight || (yield* Ref.get(phaseRef)) === "starting") { + return yield* Effect.fail( + new StackBuildError({ + detail: `Cannot configure preload for extension "${name}" while the stack is starting. Wait for start() to finish and retry.`, + }), + ); + } + // PGDATA I/O failures (missing dir, bad permissions) surface as + // typed StackBuildErrors so daemon routes serialize them instead + // of dying with an unstructured 500. + const podConfIo = (detail: string, io: () => Promise) => + Effect.tryPromise({ + try: io, + catch: (cause) => new StackBuildError({ detail, cause }), + }); + const result = yield* podConfIo( + `Failed to configure preload for extension "${name}"`, + () => configureExtensionPreload(config.postgres.dataDir, name), + ); + if (result !== "updated") { + return; + } + if ((yield* Ref.get(phaseRef)) !== "running") { + return; + } + yield* requireKnownService("postgres"); + const runtime = yield* ensureRuntime; + yield* runtime.orchestrator.restartService("postgres"); + yield* markStarted(["postgres"]); + yield* runtime.orchestrator.waitReady("postgres"); + }), + ), reloadFunctions: (opts) => Effect.gen(function* () { yield* requireKnownService("edge-runtime"); const runtime = yield* ensureRuntime; + yield* activateServices(runtime, ["edge-runtime"]); yield* configureFunctions(configWithFunctionOptions(opts)); - yield* runtime.orchestrator.restartService("edge-runtime"); + const started = yield* Ref.get(startedServicesRef); + if (started.has("edge-runtime")) { + yield* runtime.orchestrator.restartService("edge-runtime"); + } else { + yield* runtime.orchestrator.startService("edge-runtime"); + } + yield* markStarted(["edge-runtime"]); yield* runtime.orchestrator.waitReady("edge-runtime"); }), reloadEdgeRuntime: (opts) => Effect.gen(function* () { yield* requireKnownService("edge-runtime"); const nextConfig = yield* configWithEdgeRuntimeOptions(opts); - const prepared = yield* ensurePrepared; const runtime = yield* ensureRuntime; - const buildResult = yield* builder.build(nextConfig, prepared); + yield* activateServices(runtime, ["edge-runtime"]); + const preparedServices = enabledServicesForConfig(config).filter( + (service) => preparedArtifacts.resolutions[service] !== undefined, + ); + const buildResult = yield* builder.build( + nextConfig, + preparedArtifacts, + preparedServices, + ); const edgeRuntimeDef = buildResult.graph.startOrder.find( (def) => def.name === "edge-runtime", ); @@ -589,7 +828,13 @@ export class StackLifecycleCoordinator extends Context.Service< }), ), ); - yield* runtime.orchestrator.restartService("edge-runtime"); + const started = yield* Ref.get(startedServicesRef); + if (started.has("edge-runtime")) { + yield* runtime.orchestrator.restartService("edge-runtime"); + } else { + yield* runtime.orchestrator.startService("edge-runtime"); + } + yield* markStarted(["edge-runtime"]); yield* runtime.orchestrator.waitReady("edge-runtime"); }), getState: (name) => @@ -611,13 +856,47 @@ export class StackLifecycleCoordinator extends Context.Service< waitReady: (name) => Effect.gen(function* () { yield* requireKnownService(name); + const startedServices = yield* Ref.get(startedServicesRef); + if (!startedServices.has(name)) { + return yield* Effect.fail( + new StackBuildError({ + detail: `Service "${name}" has not been started yet.`, + }), + ); + } const runtime = yield* ensureRuntime; yield* runtime.orchestrator.waitReady(name); }), waitAllReady: () => Effect.gen(function* () { const runtime = yield* ensureRuntime; - yield* runtime.orchestrator.waitAllReady(); + // Readiness is only meaningful once start() has completed: before that the + // started set may still be empty (start() records the eager postgres set only + // after launching it), so an early snapshot would vacuously report "ready" while + // postgres is still coming up. Fail instead — the daemon /ready route serializes + // this as a 500, which is what a health poller should see mid-start (or after + // stop). + const phase = yield* Ref.get(phaseRef); + if (phase !== "running") { + return yield* Effect.fail( + new StackBuildError({ + detail: `Stack is not running (phase: "${phase}"); readiness is only defined once start() has completed.`, + }), + ); + } + // Lazy: "ready" means "every service that has been started is ready". Services + // that were never started (e.g. postgrest, still Pending until the ApiProxy's + // ensureService calls startService on demand) never resolve their `healthy` + // deferred and never emit a Failed state either, so waiting on them would hang + // forever. Snapshot the started set at call time — if a service starts + // concurrently between this read and the wait below, it's acceptable to not wait + // on it; callers that need to observe it can call waitReady/waitAllReady again. + const startedServices = yield* Ref.get(startedServicesRef); + yield* Effect.forEach( + startedServices, + (name) => waitReadyKnownService(runtime.orchestrator, name), + { concurrency: "unbounded" }, + ); }), subscribeLogs: (name) => logBuffer.subscribe(name), subscribeAllLogs: (services) => diff --git a/packages/stack/src/StackLifecycleCoordinator.unit.test.ts b/packages/stack/src/StackLifecycleCoordinator.unit.test.ts new file mode 100644 index 0000000000..a724d1836d --- /dev/null +++ b/packages/stack/src/StackLifecycleCoordinator.unit.test.ts @@ -0,0 +1,519 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import * as http from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { NodeServices } from "@effect/platform-node"; +import { Duration, Effect, Exit, Fiber, Layer } from "effect"; +import { mockChildProcessSpawner } from "../../process-compose/tests/helpers/mocks.ts"; +import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; +import { defaultPublishableKey, defaultSecretKey, generateJwt } from "./JwtGenerator.ts"; +import { StackBuildError } from "./errors.ts"; +import { readPreloadLibraries } from "./pgconf.ts"; +import type { AllocatedPorts } from "./PortAllocator.ts"; +import { Stack } from "./Stack.ts"; +import { StackLifecycleCoordinator } from "./StackLifecycleCoordinator.ts"; +import { StackMetadataPersistence } from "./StackMetadataPersistence.ts"; +import { StackPreparation } from "./StackPreparation.ts"; +import { StackBuilder } from "./StackBuilder.ts"; +import type { ResolvedStackConfig } from "./StackBuilder.ts"; +import { DEFAULT_VERSIONS } from "./versions.ts"; + +const testJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long"; + +/** + * The coordinator's projected status is fed by an async stream, so it lags the + * orchestrator's internal readiness signal that waitReady/waitAllReady resolve on — + * on slow machines it can transiently read "Starting" (still catching up) or even + * "Restarting" (a flaky health probe briefly failed). Only its EVENTUAL value is + * contractual, so poll until it settles instead of asserting a snapshot. + */ +const waitForReadyStatus = (getState: Effect.Effect<{ readonly status: string }, E, R>) => + Effect.gen(function* () { + for (;;) { + const state = yield* getState; + if (state.status === "Running" || state.status === "Healthy") return; + yield* Effect.sleep(Duration.millis(25)); + } + }).pipe(Effect.timeout(Duration.seconds(5))); + +const defaultPorts: AllocatedPorts = { + apiPort: 54321, + dbPort: 54322, + authPort: 9999, + postgrestPort: 54323, + postgrestAdminPort: 54324, + edgeRuntimePort: 54325, + edgeRuntimeInspectorPort: 54326, + realtimePort: 54330, + storagePort: 54331, + imgproxyPort: 54332, + mailpitPort: 54333, + mailpitSmtpPort: 54334, + mailpitPop3Port: 54335, + pgmetaPort: 54336, + studioPort: 54337, + analyticsPort: 54338, + poolerPort: 54339, + poolerApiPort: 54340, +}; + +function makeConfig(dataDir: string): ResolvedStackConfig { + return { + cacheRoot: "/tmp/supabase-cache", + stackRoot: "/tmp/supabase-stack", + runtimeRoot: "/tmp/supabase-runtime", + projectDir: "/tmp/supabase-project", + mode: "native", + jwtSecret: testJwtSecret, + startServices: [], + ports: defaultPorts, + apiPort: 54321, + dbPort: 54322, + publishableKey: defaultPublishableKey, + secretKey: defaultSecretKey, + functions: false, + autoManagedPaths: [], + anonJwt: generateJwt(testJwtSecret, "anon"), + serviceRoleJwt: generateJwt(testJwtSecret, "service_role"), + postgres: { + port: 54322, + dataDir, + version: DEFAULT_VERSIONS.postgres, + password: "postgres", + autoExposeNewTables: true, + }, + // postgrest/auth are disabled: their health checks are real HTTP probes + // (unlike postgres's Exec-based pg_isready probe, which the mocked + // ChildProcessSpawner satisfies), so nothing in this mock setup would ever + // answer them and waitAllReady would hang/fail. Keeping only postgres + // enabled is sufficient to exercise the ensureExtensionPreload race. + postgrest: false, + auth: false, + edgeRuntime: false, + realtime: false, + storage: false, + imgproxy: false, + mailpit: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, + }; +} + +function setupLayer(config: ResolvedStackConfig) { + const resolver = mockBinaryResolver(); + const spawner = mockChildProcessSpawner(); + const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); + const coordinatorLayer = StackLifecycleCoordinator.layer(config).pipe( + Layer.provide(StackBuilder.layer), + Layer.provide(stackPreparationLayer), + Layer.provide(StackMetadataPersistence.noop), + ); + + const layer = Stack.layer(config).pipe( + Layer.provide(coordinatorLayer), + Layer.provide(spawner.layer), + Layer.provide(NodeServices.layer), + ); + + return { layer, resolver, spawner }; +} + +function makeLazyConfig(dataDir: string, postgrestPort: number): ResolvedStackConfig { + return { + ...makeConfig(dataDir), + startServices: [], + // postgrest's health check is a real HTTP probe against 127.0.0.1:postgrestPort; the test + // below binds a fake listener there so waitReady can actually resolve once startService + // spawns it (spawning itself is satisfied generically by the mocked ChildProcessSpawner). + postgrest: { + port: postgrestPort, + adminPort: defaultPorts.postgrestAdminPort, + schemas: ["public"], + extraSearchPath: ["public"], + maxRows: 1000, + version: DEFAULT_VERSIONS.postgrest, + }, + }; +} + +function makeSlowStartConfig(dataDir: string): ResolvedStackConfig { + return { + ...makeConfig(dataDir), + postgrest: { + port: defaultPorts.postgrestPort, + adminPort: defaultPorts.postgrestAdminPort, + schemas: ["public"], + extraSearchPath: ["public"], + maxRows: 1000, + version: DEFAULT_VERSIONS.postgrest, + }, + }; +} + +interface FakeHealthyServer { + readonly port: number; + readonly stop: () => Promise; +} + +function startFakeHealthyServer(): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer((_req, res) => { + res.writeHead(200); + res.end("OK"); + }); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (!addr || typeof addr === "string") { + reject(new Error("Unexpected server address")); + return; + } + resolve({ + port: addr.port, + stop: () => + new Promise((res, rej) => server.close((err) => (err ? rej(err) : res()))), + }); + }); + server.on("error", reject); + }); +} + +describe("StackLifecycleCoordinator ensureExtensionPreload", () => { + // Regression test: two concurrent ensureExtensionPreload calls used to race on + // pod.conf — both read the same preload list, both wrote independently, and + // the second write clobbered the first, silently dropping one extension + // from shared_preload_libraries. Racing the two restarts of "postgres" also + // deadlocks the orchestrator (concurrent FiberMap.run + stopForRestart calls + // on the same service name step on each other), so without the fix this + // test times out rather than merely asserting the wrong libraries. + // ensureExtensionPreload is now serialized per coordinator instance with an Effect + // Semaphore, which fixes both the lost write and the restart deadlock. + // + // it.live is required (not it.effect/TestClock): the mock ChildProcessSpawner + // resolves exit codes and the postgres health-check probe via a real + // `Effect.sleep("10 millis")`, which needs the real clock to progress. + it.live("serializes concurrent ensureExtensionPreload calls so no write is lost", () => { + const dataDir = mkdtempSync(join(tmpdir(), "stack-lifecycle-coordinator-test-")); + writeFileSync(join(dataDir, "postgresql.conf"), "# stock conf\n"); + const config = makeConfig(dataDir); + const { layer } = setupLayer(config); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + + yield* Effect.all( + [stack.ensureExtensionPreload("pg_cron"), stack.ensureExtensionPreload("pg_net")], + { + concurrency: "unbounded", + }, + ); + + const libraries = yield* Effect.promise(() => readPreloadLibraries(dataDir)); + expect(libraries).toContain("pg_cron"); + expect(libraries).toContain("pg_net"); + expect(libraries).toHaveLength(2); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true }))), + ); + }); + + it.live("lazy waitAllReady fails before start() completes instead of reporting ready", () => { + const dataDir = mkdtempSync(join(tmpdir(), "stack-lifecycle-coordinator-ready-test-")); + writeFileSync(join(dataDir, "postgresql.conf"), "# stock conf\n"); + const config: ResolvedStackConfig = { ...makeConfig(dataDir), startServices: [] }; + const { layer } = setupLayer(config); + + return Effect.gen(function* () { + const stack = yield* Stack; + + // Before start(): the started set is empty; reporting "ready" here would + // let a daemon /ready poll observe ok while postgres is still down. + const early = yield* stack.waitAllReady().pipe(Effect.exit); + expect(Exit.isFailure(early)).toBe(true); + + yield* stack.start(); + yield* stack.waitAllReady(); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true }))), + ); + }); + + it.live("rejects lazy restarts of services that were never started", () => { + const dataDir = mkdtempSync(join(tmpdir(), "stack-lifecycle-coordinator-restart-test-")); + writeFileSync(join(dataDir, "postgresql.conf"), "# stock conf\n"); + const config = makeLazyConfig(dataDir, defaultPorts.postgrestPort); + const { layer } = setupLayer(config); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + + // postgrest is enabled but lazy and never started: restart only brings + // up the target and its dependents, so it would boot waiting on + // dependencies that stay Pending forever. + const exit = yield* stack.restartService("postgrest").pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true }))), + ); + }); + + it.live("treats non-preload extensions as a no-op without touching the data dir", () => { + // Deliberately point at a data dir that doesn't exist and never start the + // stack: enabling e.g. pgvector must not read or create anything in PGDATA. + const root = mkdtempSync(join(tmpdir(), "stack-lifecycle-coordinator-nopreload-test-")); + const dataDir = join(root, "missing"); + const config = makeConfig(dataDir); + const { layer } = setupLayer(config); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.ensureExtensionPreload("vector"); + expect(existsSync(dataDir)).toBe(false); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); + + it.live("records preload libraries without restarting postgres while stopped", () => { + const dataDir = mkdtempSync(join(tmpdir(), "stack-lifecycle-coordinator-stopped-test-")); + writeFileSync(join(dataDir, "postgresql.conf"), "# stock conf\n"); + const config = makeConfig(dataDir); + const { layer, spawner } = setupLayer(config); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + yield* stack.stop(); + const spawnedBefore = spawner.spawned.length; + + yield* stack.ensureExtensionPreload("pg_cron"); + + expect(spawner.spawned).toHaveLength(spawnedBefore); + expect((yield* stack.getState("postgres")).status).toBe("Stopped"); + expect(yield* Effect.promise(() => readPreloadLibraries(dataDir))).toEqual(["pg_cron"]); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true }))), + ); + }); + + it.live("rejects preload changes while the stack is still starting", () => { + const dataDir = mkdtempSync(join(tmpdir(), "stack-lifecycle-coordinator-starting-test-")); + writeFileSync(join(dataDir, "postgresql.conf"), "# stock conf\n"); + const config = makeSlowStartConfig(dataDir); + const { layer, spawner } = setupLayer(config); + + return Effect.gen(function* () { + const stack = yield* Stack; + const startFiber = yield* stack.start().pipe(Effect.forkChild({ startImmediately: true })); + + yield* Effect.gen(function* () { + for (;;) { + if (spawner.spawned.length > 0) break; + yield* Effect.sleep(Duration.millis(10)); + } + + const exit = yield* stack.ensureExtensionPreload("pg_cron").pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(yield* Effect.promise(() => readPreloadLibraries(dataDir))).toEqual([]); + }).pipe(Effect.ensuring(Fiber.interrupt(startFiber))); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true }))), + ); + }); + + it.live("returns to stopped phase after start fails", () => { + const dataDir = mkdtempSync(join(tmpdir(), "stack-lifecycle-coordinator-failed-start-test-")); + writeFileSync(join(dataDir, "postgresql.conf"), "# stock conf\n"); + const config = makeConfig(dataDir); + const resolver = mockBinaryResolver(); + const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); + const failingBuilderLayer = Layer.succeed(StackBuilder, { + build: () => Effect.fail(new StackBuildError({ detail: "build failed" })), + }); + const layer = StackLifecycleCoordinator.layer(config).pipe( + Layer.provide(failingBuilderLayer), + Layer.provide(stackPreparationLayer), + Layer.provide(StackMetadataPersistence.noop), + Layer.provide(NodeServices.layer), + ); + + return Effect.gen(function* () { + const coordinator = yield* StackLifecycleCoordinator; + const startExit = yield* coordinator.start().pipe(Effect.exit); + + expect(Exit.isFailure(startExit)).toBe(true); + yield* coordinator.ensureExtensionPreload("pg_cron"); + expect(yield* Effect.promise(() => readPreloadLibraries(dataDir))).toEqual(["pg_cron"]); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true }))), + ); + }); +}); + +describe("StackLifecycleCoordinator lazy service activation", () => { + // it.live: the mocked ChildProcessSpawner and postgres's health-check probe both resolve via a + // real `Effect.sleep`, which needs the real clock to progress (same reason as the + // ensureExtensionPreload test above). + it.live("start() only eager-starts postgres/postgres-init; postgrest starts on demand", () => { + const dataDir = mkdtempSync(join(tmpdir(), "stack-lifecycle-coordinator-lazy-test-")); + + return Effect.promise(() => startFakeHealthyServer()).pipe( + Effect.flatMap((fakeServer) => { + const config = makeLazyConfig(dataDir, fakeServer.port); + const { layer, resolver, spawner } = setupLayer(config); + + const isPostgrestPayload = (s: { args: ReadonlyArray }) => { + const encoded = s.args.at(-1); + if (encoded === undefined) return false; + try { + const decoded = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as { + command?: string; + }; + return decoded.command?.endsWith("/postgrest") ?? false; + } catch { + return false; + } + }; + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + + // Starting the stack prepares and launches only Postgres. + expect(spawner.spawned.some(isPostgrestPayload)).toBe(false); + expect(resolver.resolved.map(({ service }) => service)).toEqual(["postgres"]); + + const postgrestState = yield* stack.getState("postgrest"); + expect(postgrestState.status).toBe("Pending"); + + // The ApiProxy's ensureService would call startService + waitReady on first request; + // simulate that here directly against the coordinator. + yield* stack.startService("postgrest"); + yield* stack.waitReady("postgrest"); + + expect(spawner.spawned.some(isPostgrestPayload)).toBe(true); + expect(resolver.resolved.map(({ service }) => service)).toEqual([ + "postgres", + "postgrest", + ]); + // waitReady already proved the service reached a ready state — that's the actual + // assertion above. The projected status only settles eventually; see helper doc. + yield* waitForReadyStatus(stack.getState("postgrest")); + + yield* Effect.promise(() => fakeServer.stop()); + }).pipe(Effect.provide(layer)); + }), + Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true }))), + ); + }); + + it.live("rejects on-demand service starts before start()", () => { + const dataDir = mkdtempSync(join(tmpdir(), "stack-lifecycle-coordinator-lazy-idle-test-")); + const config = makeLazyConfig(dataDir, defaultPorts.postgrestPort); + const { layer } = setupLayer(config); + + return Effect.gen(function* () { + const stack = yield* Stack; + const exit = yield* stack.startService("postgrest").pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("while the stack is idle"); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true }))), + ); + }); + + it.live("rejects on-demand service starts after stop()", () => { + const dataDir = mkdtempSync(join(tmpdir(), "stack-lifecycle-coordinator-lazy-stopped-test-")); + const config = makeLazyConfig(dataDir, defaultPorts.postgrestPort); + const { layer } = setupLayer(config); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + yield* stack.stop(); + + const exit = yield* stack.startService("postgrest").pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("while the stack is stopped"); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true }))), + ); + }); + + // Regression test: waitAllReady() used to unconditionally delegate to + // orchestrator.waitAllReady() over the full graph startOrder. Lazily activated services + // that were never started (e.g. postgrest, which stays "Pending" until the ApiProxy's + // ensureService calls startService on first request) never resolve their `healthy` deferred + // and never emit a Failed state either, so the old implementation hung forever. This is the + // red test against the pre-fix code: bounded with a short timeout so it fails fast (as a + // timeout) rather than hanging the suite. + it.live("waitAllReady() resolves promptly when only postgres was eager-started", () => { + const dataDir = mkdtempSync(join(tmpdir(), "stack-lifecycle-coordinator-lazy-ready-test-")); + // postgrest is never started in this test, so nothing binds to its configured port; any + // fixed port is safe here (no EADDRINUSE risk). + const config = makeLazyConfig(dataDir, defaultPorts.postgrestPort); + const { layer } = setupLayer(config); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + + // postgrest was never started; under the old behavior this would hang because + // orchestrator.waitAllReady() waits on the full graph, including never-started services. + yield* stack.waitAllReady().pipe(Effect.timeout(Duration.seconds(5))); + + const postgrestState = yield* stack.getState("postgrest"); + expect(postgrestState.status).toBe("Pending"); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true }))), + ); + }); + + it.live("waitAllReady() covers a service started on demand after start()", () => { + const dataDir = mkdtempSync( + join(tmpdir(), "stack-lifecycle-coordinator-lazy-ready-started-test-"), + ); + + return Effect.promise(() => startFakeHealthyServer()).pipe( + Effect.flatMap((fakeServer) => { + const config = makeLazyConfig(dataDir, fakeServer.port); + const { layer } = setupLayer(config); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + + // Simulate the ApiProxy's ensureService on-demand path. + yield* stack.startService("postgrest"); + + yield* stack.waitAllReady().pipe(Effect.timeout(Duration.seconds(5))); + + // waitAllReady covering the on-demand service means it reaches ready; the + // projected status only settles eventually; see helper doc. + yield* waitForReadyStatus(stack.getState("postgrest")); + + yield* Effect.promise(() => fakeServer.stop()); + }).pipe(Effect.provide(layer)); + }), + Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true }))), + ); + }); +}); diff --git a/packages/stack/src/StackPreparation.ts b/packages/stack/src/StackPreparation.ts index 1c3a856bee..5877e83a5b 100644 --- a/packages/stack/src/StackPreparation.ts +++ b/packages/stack/src/StackPreparation.ts @@ -1,8 +1,8 @@ import { Cause, Data, Effect, Exit, Layer, Queue, Context, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { BinaryResolver } from "./BinaryResolver.ts"; -import type { ChecksumMismatchError } from "./errors.ts"; -import { DockerPullError } from "./errors.ts"; +import type { BinaryNotFoundError, ChecksumMismatchError, DownloadError } from "./errors.ts"; +import { DockerPullError, StackBuildError } from "./errors.ts"; import type { ServiceResolution } from "./resolve.ts"; import { DEFAULT_VERSIONS, @@ -39,6 +39,13 @@ type StackPreparationEvent = | ServiceDownloadFinished | PreparationCompleted; +export type StackPreparationError = + | DockerPullError + | ChecksumMismatchError + | StackBuildError + | BinaryNotFoundError + | DownloadError; + const dockerOnlyServices = new Set([ "edge-runtime", "realtime", @@ -86,7 +93,7 @@ export const prepareAssetsWithDependencies = ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], input?: StackPreparationInput, publishEvent?: (event: StackPreparationEvent) => Effect.Effect, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const versions = { ...DEFAULT_VERSIONS, ...input?.versions }; const services: ReadonlyArray = input?.services ?? SERVICE_NAMES; @@ -94,9 +101,7 @@ export const prepareAssetsWithDependencies = ( type Entry = readonly [ServiceName, ServiceResolution]; - const resolveService = ( - service: ServiceName, - ): Effect.Effect => { + const resolveService = (service: ServiceName): Effect.Effect => { let isDownloading = false; const markDownloadStart = () => Effect.sync(() => { @@ -120,6 +125,29 @@ export const prepareAssetsWithDependencies = ( ); } + if (mode === "native") { + // Native mode is a hard requirement, not a preference: callers like + // fleet pods key process reconciliation off host postmaster pids, so + // silently booting a Docker container instead would leave crashed + // pods unreapable. Fail fast rather than fall back. + if (dockerOnlyServices.has(service)) { + return Effect.fail( + new StackBuildError({ + detail: `Service "${service}" has no native distribution and cannot run with mode "native".`, + }), + ); + } + return resolver + .resolveWithMetadata( + { service, version: versions[service] }, + { onDownloadStart: markDownloadStart() }, + ) + .pipe( + Effect.map(({ path }): Entry => [service, { type: "binary", path }]), + Effect.ensuring(markDownloadFinished()), + ); + } + if (dockerOnlyServices.has(service)) { return resolveDockerImageForService(spawner, service, versions[service], { onDownloadStart: markDownloadStart(), @@ -157,10 +185,10 @@ export class StackPreparation extends Context.Service< { readonly prepare: ( input?: StackPreparationInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly prepareEvents: ( input?: StackPreparationInput, - ) => Stream.Stream; + ) => Stream.Stream; } >()("stack/StackPreparation") { static layer: Layer.Layer< @@ -177,7 +205,7 @@ export class StackPreparation extends Context.Service< prepare: (input?: StackPreparationInput) => prepareAssetsWithDependencies(resolver, spawner, input), prepareEvents: (input?: StackPreparationInput) => - Stream.callback((queue) => + Stream.callback((queue) => prepareAssetsWithDependencies(resolver, spawner, input, (event) => Queue.offer(queue, event), ).pipe( diff --git a/packages/stack/src/UnixSocketSse.integration.test.ts b/packages/stack/src/UnixSocketSse.integration.test.ts index e6694814da..ffd1361a2e 100644 --- a/packages/stack/src/UnixSocketSse.integration.test.ts +++ b/packages/stack/src/UnixSocketSse.integration.test.ts @@ -67,6 +67,8 @@ function makeStackLayer(opts: { name === "postgres" ? Effect.void : Effect.fail(new ServiceNotFoundError({ name })), restartService: (name: string) => name === "postgres" ? Effect.void : Effect.fail(new ServiceNotFoundError({ name })), + ensureExtensionPreload: (name: string) => + name === "postgres" ? Effect.void : Effect.fail(new ServiceNotFoundError({ name })), reloadFunctions: () => Effect.void, reloadEdgeRuntime: () => Effect.void, getState: (name: string) => diff --git a/packages/stack/src/bun.ts b/packages/stack/src/bun.ts index 9d48a77a7b..28ad707ab4 100644 --- a/packages/stack/src/bun.ts +++ b/packages/stack/src/bun.ts @@ -5,7 +5,7 @@ import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { BinaryResolver } from "./BinaryResolver.ts"; import { - createStack as createStackCore, + createReadyStack as createStackCore, type PlatformFactory, type StackHandle, } from "./createStack.ts"; diff --git a/packages/stack/src/cleanup.ts b/packages/stack/src/cleanup.ts index 59ee0d2cae..773bd5570b 100644 --- a/packages/stack/src/cleanup.ts +++ b/packages/stack/src/cleanup.ts @@ -16,6 +16,30 @@ export function dockerForceRemove(containerNames: ReadonlyArray): void { } } +function commandStderr(error: unknown): string { + if (typeof error !== "object" || error === null || !("stderr" in error)) return ""; + const stderr = error.stderr; + if (typeof stderr === "string") return stderr; + if (stderr instanceof Uint8Array) return new TextDecoder().decode(stderr); + return ""; +} + +function dockerForceRemoveStrict(containerNames: ReadonlyArray): void { + const errors: unknown[] = []; + for (const name of containerNames) { + try { + execFileSync("docker", ["rm", "-f", name], { stdio: "pipe", timeout: 5_000 }); + } catch (error) { + if (commandStderr(error).includes("No such container")) continue; + errors.push(new Error(`Failed to remove Docker container ${name}`, { cause: error })); + } + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError(errors, "Failed to remove Stack Docker containers"); + } +} + export function cleanupAutoManagedPaths(config: ResolvedStackConfig): void { if (config.autoManagedPaths.length === 0) { return; @@ -60,6 +84,13 @@ const cleanupAutoManagedPathsWithRetry = (config: ResolvedStackConfig): Effect.E yield* Effect.sleep(Duration.millis(250)); } + + const remaining = cleanupTargets + .filter((target) => existsSync(target.path)) + .map((target) => target.path); + if (remaining.length > 0) { + throw new Error(`Failed to remove Stack runtime paths: ${remaining.join(", ")}`); + } }); export const cleanupLocalStackResources = (opts: { @@ -67,18 +98,13 @@ export const cleanupLocalStackResources = (opts: { readonly cleanupTargets: CleanupTargets; readonly config: ResolvedStackConfig; }): Effect.Effect => - Effect.gen(function* () { - // Best-effort graceful shutdown — stop() may fail if services already - // exited or the scope is partially closed. Make the stop path - // uninterruptible so SIGTERM-driven scope closure does not abandon it - // mid-shutdown and leak child processes. - yield* Effect.uninterruptible(opts.stop()).pipe(Effect.catch(() => Effect.void)); - - // 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* Effect.sync(() => { - dockerForceRemove(opts.cleanupTargets.dockerContainerNames); - }); - yield* cleanupAutoManagedPathsWithRetry(opts.config); - }); + // All cleanup stages run even when an earlier stage fails. `ensuring` + // preserves the combined failure cause, so callers never receive a false + // success while a service, container, or runtime directory may remain. + Effect.uninterruptible(opts.stop()).pipe( + Effect.ensuring( + Effect.sync(() => dockerForceRemoveStrict(opts.cleanupTargets.dockerContainerNames)).pipe( + Effect.ensuring(cleanupAutoManagedPathsWithRetry(opts.config)), + ), + ), + ); diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index 0684ac867b..7196358a8c 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -16,6 +16,7 @@ import { defaultSecretKey, generateJwt, } from "./JwtGenerator.ts"; +import { resolvePostgresPassword } from "./postgresCredentials.ts"; import { daemonLayer, foregroundLayer, @@ -31,6 +32,8 @@ import { shortTempPrefixRoot, } from "./paths.ts"; import { allocatePorts, DEFAULT_PORTS, PORT_FIELDS, type AllocatedPorts } from "./PortAllocator.ts"; +import type { PortInput } from "./PortAllocator.ts"; +import { acquirePortLease } from "./PortLease.ts"; import { StackMetadataSchema } from "./StackMetadata.ts"; import { InvalidStackStateError, StackAlreadyRunningError } from "./StateManager.ts"; import { Stack } from "./Stack.ts"; @@ -65,7 +68,7 @@ import type { StudioConfig, VectorConfig, } from "./StackBuilder.ts"; -import { DEFAULT_VERSIONS } from "./versions.ts"; +import { DEFAULT_VERSIONS, STACK_SERVICE_NAMES, type StackServiceName } from "./versions.ts"; const StackMetadataFileSchema = Schema.fromJsonString(StackMetadataSchema); const decodeStackMetadataFile = Schema.decodeUnknownSync(StackMetadataFileSchema); @@ -98,6 +101,7 @@ export interface StackHandle extends AsyncDisposable { startService(name: string): Promise; stopService(name: string): Promise; restartService(name: string): Promise; + ensureExtensionPreload(name: string): Promise; reloadFunctions(opts?: FunctionsConfig): Promise; reloadEdgeRuntime(opts: EdgeRuntimeReloadConfig): Promise; ready(opts?: ReadyOptions): Promise; @@ -115,6 +119,7 @@ interface ResolveConfigOptions { readonly runtimeRoot?: string; readonly preferredPorts?: Partial; readonly reservedPorts?: ReadonlySet; + readonly allocatedPorts?: AllocatedPorts; } interface ResolvedRoots { @@ -270,10 +275,8 @@ async function readReservedPortsInStacksRoot( function resolvePostgrestConfig( input: PostgrestConfig | undefined, - raw: PostgrestConfig | false | undefined, ports: AllocatedPorts, -): ResolvedPostgrestConfig | false { - if (raw === false) return false; +): ResolvedPostgrestConfig { const cfg = input ?? {}; return { port: ports.postgrestPort, @@ -287,11 +290,9 @@ function resolvePostgrestConfig( function resolveAuthConfig( input: AuthConfig | undefined, - raw: AuthConfig | false | undefined, ports: AllocatedPorts, apiPort: number, -): ResolvedAuthConfig | false { - if (raw === false) return false; +): ResolvedAuthConfig { const cfg = input ?? {}; return { port: ports.authPort, @@ -304,10 +305,8 @@ function resolveAuthConfig( function resolveRealtimeConfig( input: RealtimeConfig | undefined, - raw: RealtimeConfig | false | undefined, ports: AllocatedPorts, -): ResolvedRealtimeConfig | false { - if (raw === false) return false; +): ResolvedRealtimeConfig { const cfg = input ?? {}; return { port: ports.realtimePort, @@ -322,13 +321,11 @@ function resolveRealtimeConfig( function resolveEdgeRuntimeConfig( input: EdgeRuntimeConfig | undefined, - raw: EdgeRuntimeConfig | false | undefined, ports: AllocatedPorts, -): ResolvedEdgeRuntimeConfig | false { - if (raw === false || raw?.enabled === false) return false; +): ResolvedEdgeRuntimeConfig { const cfg = input ?? {}; return { - enabled: cfg.enabled ?? true, + enabled: true, port: ports.edgeRuntimePort, inspectorPort: ports.edgeRuntimeInspectorPort, policy: cfg.policy ?? "per_worker", @@ -347,11 +344,9 @@ function resolveFunctionsConfig(config: StackConfig) { function resolveStorageConfig( input: StorageConfig | undefined, - raw: StorageConfig | false | undefined, ports: AllocatedPorts, opts: ResolveConfigOptions, -): ResolvedStorageConfig | false { - if (raw === false) return false; +): ResolvedStorageConfig { const cfg = input ?? {}; return { port: ports.storagePort, @@ -364,10 +359,8 @@ function resolveStorageConfig( function resolveImgproxyConfig( input: ImgproxyConfig | undefined, - raw: ImgproxyConfig | false | undefined, ports: AllocatedPorts, -): ResolvedImgproxyConfig | false { - if (raw === false) return false; +): ResolvedImgproxyConfig { const cfg = input ?? {}; return { port: ports.imgproxyPort, @@ -377,10 +370,8 @@ function resolveImgproxyConfig( function resolveMailpitConfig( input: MailpitConfig | undefined, - raw: MailpitConfig | false | undefined, ports: AllocatedPorts, -): ResolvedMailpitConfig | false { - if (raw === false) return false; +): ResolvedMailpitConfig { const cfg = input ?? {}; return { port: ports.mailpitPort, @@ -394,10 +385,8 @@ function resolveMailpitConfig( function resolvePgmetaConfig( input: PgmetaConfig | undefined, - raw: PgmetaConfig | false | undefined, ports: AllocatedPorts, -): ResolvedPgmetaConfig | false { - if (raw === false) return false; +): ResolvedPgmetaConfig { const cfg = input ?? {}; return { port: ports.pgmetaPort, @@ -407,11 +396,9 @@ function resolvePgmetaConfig( function resolveStudioConfig( input: StudioConfig | undefined, - raw: StudioConfig | false | undefined, ports: AllocatedPorts, apiPort: number, -): ResolvedStudioConfig | false { - if (raw === false) return false; +): ResolvedStudioConfig { const cfg = input ?? {}; return { port: ports.studioPort, @@ -422,10 +409,8 @@ function resolveStudioConfig( function resolveAnalyticsConfig( input: AnalyticsConfig | undefined, - raw: AnalyticsConfig | false | undefined, ports: AllocatedPorts, -): ResolvedAnalyticsConfig | false { - if (raw === false) return false; +): ResolvedAnalyticsConfig { const cfg = input ?? {}; return { port: ports.analyticsPort, @@ -435,11 +420,7 @@ function resolveAnalyticsConfig( }; } -function resolveVectorConfig( - input: VectorConfig | undefined, - raw: VectorConfig | false | undefined, -): ResolvedVectorConfig | false { - if (raw === false) return false; +function resolveVectorConfig(input: VectorConfig | undefined): ResolvedVectorConfig { const cfg = input ?? {}; return { version: cfg.version ?? DEFAULT_VERSIONS.vector, @@ -448,10 +429,8 @@ function resolveVectorConfig( function resolvePoolerConfig( input: PoolerConfig | undefined, - raw: PoolerConfig | false | undefined, ports: AllocatedPorts, -): ResolvedPoolerConfig | false { - if (raw === false) return false; +): ResolvedPoolerConfig { const cfg = input ?? {}; return { port: ports.poolerPort, @@ -476,64 +455,84 @@ export async function resolveConfig( 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 = - !(resolvedMode === "native" && config.edgeRuntime === undefined) && - config.edgeRuntime !== false && - (config.edgeRuntime?.enabled ?? true) !== false; - const realtimeEnabled = config.realtime !== undefined && config.realtime !== false; - const storageEnabled = config.storage !== undefined && config.storage !== false; - const imgproxyEnabled = config.imgproxy !== undefined && config.imgproxy !== false; - const mailpitEnabled = config.mailpit !== undefined && config.mailpit !== false; - const pgmetaEnabled = config.pgmeta !== undefined && config.pgmeta !== false; - const studioEnabled = config.studio !== undefined && config.studio !== false; - const analyticsEnabled = config.analytics !== undefined && config.analytics !== false; - const vectorEnabled = config.vector !== undefined && config.vector !== false; - const poolerEnabled = config.pooler !== undefined && config.pooler !== false; - const edgeRuntimeInput = edgeRuntimeEnabled ? (config.edgeRuntime ?? undefined) : undefined; - const realtimeInput = realtimeEnabled ? (config.realtime ?? undefined) : undefined; - const storageInput = storageEnabled ? (config.storage ?? undefined) : undefined; - const imgproxyInput = imgproxyEnabled ? (config.imgproxy ?? undefined) : undefined; - const mailpitInput = mailpitEnabled ? (config.mailpit ?? undefined) : undefined; - const pgmetaInput = pgmetaEnabled ? (config.pgmeta ?? undefined) : undefined; - const studioInput = studioEnabled ? (config.studio ?? undefined) : undefined; - const analyticsInput = analyticsEnabled ? (config.analytics ?? undefined) : undefined; - const vectorInput = vectorEnabled ? (config.vector ?? undefined) : undefined; - const poolerInput = poolerEnabled ? (config.pooler ?? undefined) : undefined; + // Docker-only sidecars cannot be made available in native mode. Keep the native default + // useful without silently selecting services that the requested runtime can never start. + const requestedServices = + config.services ?? + (resolvedMode === "native" + ? (["postgrest", "auth"] satisfies StackServiceName[]) + : STACK_SERVICE_NAMES); + const knownServices = new Set(STACK_SERVICE_NAMES); + const enabledServices = new Set(); + for (const service of requestedServices) { + if (!knownServices.has(service)) throw new Error(`Unknown stack service: ${service}`); + if (enabledServices.has(service)) throw new Error(`Duplicate stack service: ${service}`); + enabledServices.add(service); + } + const serviceInputs = { + postgrest: config.postgrest, + auth: config.auth, + "edge-runtime": config.edgeRuntime, + realtime: config.realtime, + storage: config.storage, + imgproxy: config.imgproxy, + mailpit: config.mailpit, + pgmeta: config.pgmeta, + studio: config.studio, + analytics: config.analytics, + vector: config.vector, + pooler: config.pooler, + } satisfies Record; + for (const service of STACK_SERVICE_NAMES) { + if (serviceInputs[service] !== undefined && !enabledServices.has(service)) { + throw new Error( + `Configuration for ${service} was provided, but ${service} is not listed in services`, + ); + } + } + const startServices = config.startServices ?? []; + const eagerServices = new Set(); + for (const service of startServices) { + if (!knownServices.has(service)) throw new Error(`Unknown start service: ${service}`); + if (!enabledServices.has(service)) { + throw new Error(`Start service ${service} is not listed in services`); + } + if (eagerServices.has(service)) throw new Error(`Duplicate start service: ${service}`); + eagerServices.add(service); + } + if ( + config.functions !== undefined && + config.functions !== false && + !enabledServices.has("edge-runtime") + ) { + throw new Error('functions configuration requires "edge-runtime" in services'); + } + + const postgrestEnabled = enabledServices.has("postgrest"); + const authEnabled = enabledServices.has("auth"); + const edgeRuntimeEnabled = enabledServices.has("edge-runtime"); + const realtimeEnabled = enabledServices.has("realtime"); + const storageEnabled = enabledServices.has("storage"); + const imgproxyEnabled = enabledServices.has("imgproxy"); + const mailpitEnabled = enabledServices.has("mailpit"); + const pgmetaEnabled = enabledServices.has("pgmeta"); + const studioEnabled = enabledServices.has("studio"); + const analyticsEnabled = enabledServices.has("analytics"); + const vectorEnabled = enabledServices.has("vector"); + const poolerEnabled = enabledServices.has("pooler"); const postgresDataDir = resolveDataDir(postgresInput.dataDir, roots.stackRoot, "postgres"); - const ports = await Effect.runPromise( - allocatePorts( - { - apiPort: config.port, - dbPort: postgresInput.port, - authPort: authInput?.port, - postgrestPort: undefined, - postgrestAdminPort: undefined, - edgeRuntimePort: edgeRuntimeInput?.port, - edgeRuntimeInspectorPort: edgeRuntimeInput?.inspectorPort, - realtimePort: realtimeInput?.port, - storagePort: storageInput?.port, - imgproxyPort: imgproxyInput?.port, - mailpitPort: mailpitInput?.port, - mailpitSmtpPort: mailpitInput?.smtpPort, - mailpitPop3Port: mailpitInput?.pop3Port, - pgmetaPort: pgmetaInput?.port, - studioPort: studioInput?.port, - analyticsPort: analyticsInput?.port, - poolerPort: poolerInput?.port, - poolerApiPort: poolerInput?.apiPort, - }, - { + const ports = + opts.allocatedPorts ?? + (await Effect.runPromise( + allocatePorts(portInputForConfig(config), { preferred: opts.preferredPorts, reserved: opts.reservedPorts, - }, - ), - ).catch((error: unknown) => { - throw toStackError(error); - }); + }), + ).catch((error: unknown) => { + throw toStackError(error); + })); const jwtSecret = config.jwtSecret ?? defaultJwtSecret; const anonJwt = generateJwt(jwtSecret, "anon"); @@ -546,6 +545,7 @@ export async function resolveConfig( projectDir, mode: resolvedMode, jwtSecret, + startServices: [...eagerServices], ports, apiPort: ports.apiPort, dbPort: ports.dbPort, @@ -559,35 +559,51 @@ export async function resolveConfig( port: ports.dbPort, dataDir: postgresDataDir, version: postgresInput.version ?? DEFAULT_VERSIONS.postgres, + password: resolvePostgresPassword(postgresInput.password), autoExposeNewTables: postgresInput.autoExposeNewTables ?? true, + provisioned: postgresInput.provisioned, + profile: postgresInput.profile, }, - postgrest: resolvePostgrestConfig(postgrestInput, config.postgrest, ports), - auth: resolveAuthConfig(authInput, config.auth, ports, ports.apiPort), - edgeRuntime: edgeRuntimeEnabled - ? resolveEdgeRuntimeConfig(edgeRuntimeInput, config.edgeRuntime, ports) - : false, - realtime: realtimeEnabled - ? resolveRealtimeConfig(realtimeInput, config.realtime, ports) - : false, + postgrest: postgrestEnabled ? resolvePostgrestConfig(config.postgrest, ports) : false, + auth: authEnabled ? resolveAuthConfig(config.auth, ports, ports.apiPort) : false, + edgeRuntime: edgeRuntimeEnabled ? resolveEdgeRuntimeConfig(config.edgeRuntime, ports) : false, + realtime: realtimeEnabled ? resolveRealtimeConfig(config.realtime, ports) : false, storage: storageEnabled - ? resolveStorageConfig(storageInput, config.storage, ports, { + ? resolveStorageConfig(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, 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, + imgproxy: imgproxyEnabled ? resolveImgproxyConfig(config.imgproxy, ports) : false, + mailpit: mailpitEnabled ? resolveMailpitConfig(config.mailpit, ports) : false, + pgmeta: pgmetaEnabled ? resolvePgmetaConfig(config.pgmeta, ports) : false, + studio: studioEnabled ? resolveStudioConfig(config.studio, ports, ports.apiPort) : false, + analytics: analyticsEnabled ? resolveAnalyticsConfig(config.analytics, ports) : false, + vector: vectorEnabled ? resolveVectorConfig(config.vector) : false, + pooler: poolerEnabled ? resolvePoolerConfig(config.pooler, ports) : false, + }; +} + +function portInputForConfig(config: StackConfig): PortInput { + return { + apiPort: config.port, + dbPort: config.postgres?.port, + authPort: config.auth?.port, + postgrestPort: config.postgrest?.port, + postgrestAdminPort: config.postgrest?.adminPort, + edgeRuntimePort: config.edgeRuntime?.port, + edgeRuntimeInspectorPort: config.edgeRuntime?.inspectorPort, + realtimePort: config.realtime?.port, + storagePort: config.storage?.port, + imgproxyPort: config.imgproxy?.port, + mailpitPort: config.mailpit?.port, + mailpitSmtpPort: config.mailpit?.smtpPort, + mailpitPop3Port: config.mailpit?.pop3Port, + pgmetaPort: config.pgmeta?.port, + studioPort: config.studio?.port, + analyticsPort: config.analytics?.port, + poolerPort: config.pooler?.port, + poolerApiPort: config.pooler?.apiPort, }; } @@ -691,11 +707,29 @@ function possibleCleanupTargetsForConfig(config: ResolvedStackConfig): CleanupTa return { dockerContainerNames }; } -export async function createStack( +export async function createStackController( config: StackConfig | undefined, platformFactory: PlatformFactory, ): Promise { - const resolved = await resolveConfig(config); + const rawConfig = config ?? {}; + const portLease = await acquirePortLease( + rawConfig.cacheRoot ?? defaultCacheRoot(), + portInputForConfig(rawConfig), + ); + let resolved: ResolvedStackConfig; + try { + resolved = await resolveConfig(rawConfig, { allocatedPorts: portLease.ports }); + } catch (error) { + try { + await portLease.release(); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Failed to resolve and clean up stack allocation", + ); + } + throw error; + } const fullLayer = foregroundLayer(resolved, platformFactory); const runtime = ManagedRuntime.make(fullLayer); @@ -713,8 +747,25 @@ export async function createStack( throw toStackError(error); }); - const gracefulDispose = async () => { - await runtime.dispose().catch(() => {}); + let disposed = false; + let disposeInFlight: Promise | undefined; + const gracefulDispose = (): Promise => { + if (disposed) return Promise.resolve(); + if (disposeInFlight !== undefined) return disposeInFlight; + const attempt = (async () => { + // Run the retryable lifecycle cleanup before closing its Effect scope. + // Once that succeeds, runtime disposal only releases layer resources. + await run(localStack.dispose()); + await runtime.dispose().catch((error: unknown) => { + throw toStackError(error); + }); + await portLease.release(); + disposed = true; + })(); + disposeInFlight = attempt.finally(() => { + disposeInFlight = undefined; + }); + return disposeInFlight; }; const stack: StackHandle = { @@ -728,6 +779,7 @@ export async function createStack( startService: (name) => run(localStack.startService(name)), stopService: (name) => run(localStack.stopService(name)), restartService: (name) => run(localStack.restartService(name)), + ensureExtensionPreload: (name) => run(localStack.ensureExtensionPreload(name)), reloadFunctions: (opts) => run(localStack.reloadFunctions(opts)), reloadEdgeRuntime: (opts) => run(localStack.reloadEdgeRuntime(opts)), ready: (opts) => { @@ -755,9 +807,42 @@ export async function createStack( return stack; } catch (error: unknown) { - await runtime.dispose().catch(() => {}); + let cleanupError: unknown; + await runtime.dispose().catch((cause: unknown) => { + cleanupError = toStackError(cause); + }); dockerForceRemove(possibleCleanupTargetsForConfig(resolved).dockerContainerNames); cleanupAutoManagedPaths(resolved); + if (cleanupError === undefined) { + await portLease.release().catch((cause: unknown) => { + cleanupError = cause; + }); + } + if (cleanupError !== undefined) { + throw new AggregateError( + [toStackError(error), cleanupError], + "Failed to create and clean up stack", + ); + } throw toStackError(error); } } + +/** Public constructor contract: resolve, start, and health-gate before returning. */ +export async function createReadyStack( + config: StackConfig | undefined, + platformFactory: PlatformFactory, +): Promise { + const stack = await createStackController(config, platformFactory); + try { + await stack.start(); + return stack; + } catch (startError) { + try { + await stack.dispose(); + } catch (cleanupError) { + throw new AggregateError([startError, cleanupError], "Failed to start and clean up stack"); + } + throw startError; + } +} diff --git a/packages/stack/src/createStack.unit.test.ts b/packages/stack/src/createStack.unit.test.ts index 2c13e971b2..03d1bd689b 100644 --- a/packages/stack/src/createStack.unit.test.ts +++ b/packages/stack/src/createStack.unit.test.ts @@ -213,20 +213,24 @@ describe("resolveConfig edge runtime defaults", () => { expect(config.edgeRuntime).toBe(false); }); - it("enables edge runtime when omitted in auto mode", async () => { + it("makes every sidecar available but starts none eagerly in auto mode", async () => { const config = await resolveConfig(); expect(config.mode).toBe("auto"); - expect(config.edgeRuntime).toEqual( - expect.objectContaining({ - enabled: true, - version: DEFAULT_VERSIONS["edge-runtime"], - }), - ); + expect(config.postgrest).not.toBe(false); + expect(config.auth).not.toBe(false); + expect(config.edgeRuntime).not.toBe(false); + expect(config.realtime).not.toBe(false); + expect(config.storage).not.toBe(false); + expect(config.startServices).toEqual([]); }); - it("preserves explicit edge runtime opt-in in native mode for builder validation", async () => { - const config = await resolveConfig({ mode: "native", edgeRuntime: {} }); + it("preserves explicit edge runtime selection in native mode for builder validation", async () => { + const config = await resolveConfig({ + mode: "native", + services: ["edge-runtime"], + edgeRuntime: {}, + }); expect(config.mode).toBe("native"); expect(config.edgeRuntime).toEqual( diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index 526813e741..4affb5f06c 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -84,7 +84,7 @@ export type { } from "./StackBuilder.ts"; export { StackBuilder } from "./StackBuilder.ts"; -export type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts"; +export type { EdgeRuntimeReloadConfig, EdgeRuntimeReloadOptions, StackInfo } from "./Stack.ts"; export { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts"; export type { FunctionsConfig, @@ -98,7 +98,12 @@ export { resolveFunctionsRuntimeConfig, } from "./functions.ts"; -export type { AvailableServiceVersionUpdate, ServiceName, VersionManifest } from "./versions.ts"; +export type { + AvailableServiceVersionUpdate, + ServiceName, + StackServiceName, + VersionManifest, +} from "./versions.ts"; export { DEFAULT_VERSIONS, diffPinnedAndAvailableVersions, @@ -109,6 +114,7 @@ export { normalizeServiceVersion, normalizeServiceVersions, SERVICE_NAMES, + STACK_SERVICE_NAMES, } from "./versions.ts"; export type { StackVersionOverride, @@ -160,7 +166,7 @@ export type { StackHandle, } from "./createStack.ts"; export { - createStack, + createStackController, defaultManagedStackName, projectDaemonLayer, resolveConfig, diff --git a/packages/stack/src/extensionPreload.ts b/packages/stack/src/extensionPreload.ts new file mode 100644 index 0000000000..43079e562f --- /dev/null +++ b/packages/stack/src/extensionPreload.ts @@ -0,0 +1,35 @@ +import { PRELOAD_REQUIRED_EXTENSIONS } from "./micro.ts"; +import { installPodConfOverlay, readPreloadLibraries, writePreloadLibraries } from "./pgconf.ts"; + +export type ExtensionPreloadPlan = + | { readonly action: "none" } + | { readonly action: "update"; readonly libraries: ReadonlyArray }; + +export const planExtensionPreload = ( + name: string, + currentLibraries: ReadonlyArray, +): ExtensionPreloadPlan => { + if (!PRELOAD_REQUIRED_EXTENSIONS.has(name)) return { action: "none" }; + if (currentLibraries.includes(name)) return { action: "none" }; + return { action: "update", libraries: [...currentLibraries, name] }; +}; + +export type ExtensionPreloadResult = "not-required" | "unchanged" | "updated"; + +/** + * Idempotently persists the preload configuration required by an extension. + * Process lifecycle remains the caller's responsibility: a running stack + * restarts postgres after this function reports `updated`; a suspended pod + * simply picks up the configuration on its next wake. + */ +export async function configureExtensionPreload( + dataDir: string, + name: string, +): Promise { + if (!PRELOAD_REQUIRED_EXTENSIONS.has(name)) return "not-required"; + await installPodConfOverlay(dataDir); + const plan = planExtensionPreload(name, await readPreloadLibraries(dataDir)); + if (plan.action === "none") return "unchanged"; + await writePreloadLibraries(dataDir, plan.libraries); + return "updated"; +} diff --git a/packages/stack/src/extensionPreload.unit.test.ts b/packages/stack/src/extensionPreload.unit.test.ts new file mode 100644 index 0000000000..8b9d2c1622 --- /dev/null +++ b/packages/stack/src/extensionPreload.unit.test.ts @@ -0,0 +1,36 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { configureExtensionPreload, planExtensionPreload } from "./extensionPreload.ts"; +import { readPreloadLibraries } from "./pgconf.ts"; + +describe("planExtensionPreload", () => { + it("no-ops for extensions that do not need preload", () => { + expect(planExtensionPreload("pgvector", [])).toEqual({ action: "none" }); + }); + it("no-ops when already preloaded", () => { + expect(planExtensionPreload("pg_cron", ["pg_cron"])).toEqual({ action: "none" }); + }); + it("appends the required library otherwise", () => { + expect(planExtensionPreload("pg_cron", ["pg_net"])).toEqual({ + action: "update", + libraries: ["pg_net", "pg_cron"], + }); + }); +}); + +describe("configureExtensionPreload", () => { + it("owns idempotent preload persistence for running and suspended callers", async () => { + const dataDir = await mkdtemp(join(tmpdir(), "extension-preload-")); + try { + await writeFile(join(dataDir, "postgresql.conf"), "# stock config\n"); + await expect(configureExtensionPreload(dataDir, "pg_cron")).resolves.toBe("updated"); + await expect(configureExtensionPreload(dataDir, "pg_cron")).resolves.toBe("unchanged"); + await expect(configureExtensionPreload(dataDir, "vector")).resolves.toBe("not-required"); + await expect(readPreloadLibraries(dataDir)).resolves.toEqual(["pg_cron"]); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/stack/src/fleet/EdgeProxy.ts b/packages/stack/src/fleet/EdgeProxy.ts new file mode 100644 index 0000000000..dea4b92e1b --- /dev/null +++ b/packages/stack/src/fleet/EdgeProxy.ts @@ -0,0 +1,221 @@ +import { connect, createServer, type Server, type Socket } from "node:net"; + +export interface PodUpstream { + readonly host: string; + readonly port: number; +} + +export type PodEndpointKind = "database" | "api"; + +/** + * Cap on bytes buffered per client while `wake()` is in flight. Postgres + * startup packets and HTTP request heads are tiny (low kilobytes at most); + * 1 MiB is generous headroom for either protocol while still bounding memory + * if a client streams data at a suspended pod during a slow wake. A client + * that exceeds this cap before the backend is reachable is treated as + * misbehaving (or attacking) and has its socket destroyed. + */ +export const MAX_PREWAKE_BUFFER_BYTES = 1024 * 1024; // 1 MiB + +export interface EdgeProxyEvents { + /** Fired on connect/disconnect/bytes; IdleMonitor consumes these. */ + onActivity: ( + podId: string, + event: "connect" | "data" | "disconnect", + openConnections: number, + ) => void; +} + +interface Registration { + readonly podId: string; + readonly server: Server; + readonly sockets: Set; +} + +/** + * TCP wake-proxy: binds a pod's external port once and keeps it bound for + * the pod's lifetime. Every accepted client connection is paused, then + * `wake()` is awaited to obtain the pod's live upstream address before + * splicing bytes both ways. Activity (connect/data/disconnect) is reported + * per pod so the idle monitor can track usage without touching sockets + * itself. + * + * Design assumptions: + * - **wake() is per-connection, not deduped here.** Two concurrent + * connections to a suspended pod may each call `wake()`; dedup (e.g. + * collapsing concurrent wakes into a single in-flight promise) is the + * fleet layer's responsibility, not EdgeProxy's. Both connections must + * still succeed regardless of how many times `wake()` runs. + * - **wake() rejection destroys the client.** If `wake()` rejects, the + * accepted client socket is destroyed and no upstream connection is + * attempted. + * - **Pre-wake buffering is capped.** Bytes received from the client while + * `wake()` is in flight are buffered (see above) up to + * `MAX_PREWAKE_BUFFER_BYTES`. A client that exceeds the cap before the + * backend is reachable has its socket destroyed; the usual disconnect + * activity event still fires exactly once. + * - **Nothing here can surface as an unhandled rejection.** Both the + * rejection path and any synchronous throw from the resolution path + * (e.g. `wake()` resolving with a malformed upstream address that makes + * `net.connect()` throw) are caught and routed to the same client-destroy + * path. + */ +export class EdgeProxy { + private readonly registrations = new Map(); + + constructor(private readonly events: Partial = {}) {} + + openConnections(podId: string): number { + let total = 0; + for (const registration of this.registrations.values()) { + if (registration.podId === podId) total += registration.sockets.size; + } + return total; + } + + register( + podId: string, + endpoint: PodEndpointKind, + listenPort: number, + wake: () => Promise, + ): Promise { + const registrationId = `${podId}:${endpoint}`; + if (this.registrations.has(registrationId)) { + return Promise.reject(new Error(`edge endpoint already registered: ${registrationId}`)); + } + const sockets = new Set(); + const emit = (event: "connect" | "data" | "disconnect") => + this.events.onActivity?.(podId, event, this.openConnections(podId)); + + const server = createServer((client) => { + sockets.add(client); + emit("connect"); + + let disconnected = false; + const cleanup = () => { + if (disconnected) return; + disconnected = true; + sockets.delete(client); + emit("disconnect"); + }; + client.on("close", cleanup); + client.on("error", cleanup); + + // Hold any bytes the client sends while `wake()` is in flight. A plain + // `data` listener does not fire while the socket is paused (Node only + // delivers `data` once flowing, i.e. after `resume()`/`pipe()`), so we + // use `readable` + `read()` instead: those fire even in paused mode, + // draining the socket's internal buffer as bytes arrive and letting us + // count them without ever switching the stream into flowing mode. + // Buffered chunks are replayed to the backend once it's connected, + // then the socket is handed off to `pipe()` for the rest of the + // stream. + // + // Buffering is capped at MAX_PREWAKE_BUFFER_BYTES: a client that + // floods the socket while the backend isn't reachable yet (e.g. a + // slow wake()) is destroyed rather than allowed to grow this array + // without bound. + const buffered: Buffer[] = []; + let bufferedBytes = 0; + let overflowed = false; + const onReadable = () => { + if (overflowed) return; + for (;;) { + const chunk = client.read() as Buffer | null; + if (chunk === null) return; + bufferedBytes += chunk.length; + if (bufferedBytes > MAX_PREWAKE_BUFFER_BYTES) { + overflowed = true; + buffered.length = 0; + client.destroy(); + return; + } + buffered.push(chunk); + } + }; + client.on("readable", onReadable); + client.pause(); + + wake().then( + (upstream) => { + // The client may have already disconnected while wake() was + // in flight (including due to a pre-wake buffer overflow); don't + // bother connecting an upstream nobody needs. + if (disconnected) return; + + try { + const backend = connect(upstream.port, upstream.host); + backend.on("error", () => { + client.destroy(); + backend.destroy(); + }); + client.on("close", () => backend.destroy()); + backend.on("close", () => client.destroy()); + backend.on("data", () => emit("data")); + backend.on("connect", () => { + client.off("readable", onReadable); + client.on("data", () => emit("data")); + for (const chunk of buffered) { + backend.write(chunk); + emit("data"); + } + client.pipe(backend); + backend.pipe(client); + // The activity `data` listener above is independent of the + // listener `pipe()` attaches internally; both observe the + // same events, this isn't double-consumption of the stream. + }); + } catch { + // wake() resolved, but the upstream address it handed back was + // unusable (e.g. an invalid port that makes net.connect() + // throw synchronously). Destroy the client instead of letting + // the throw escape as an unhandled rejection. + client.destroy(); + } + }, + () => { + // wake() failed: destroy the client without letting the + // rejection escape as an unhandled rejection. + client.destroy(); + }, + ); + }); + + this.registrations.set(registrationId, { podId, server, sockets }); + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + this.registrations.delete(registrationId); + for (const sock of sockets) sock.destroy(); + try { + server.close(() => reject(error)); + } catch { + reject(error); + } + }; + server.once("error", onError); + server.listen(listenPort, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); + }); + } + + private async unregisterRegistration(registrationId: string): Promise { + const reg = this.registrations.get(registrationId); + if (!reg) return; + this.registrations.delete(registrationId); + for (const sock of reg.sockets) sock.destroy(); + await new Promise((resolve) => reg.server.close(() => resolve())); + } + + async unregister(podId: string): Promise { + const registrationIds = [...this.registrations] + .filter(([, registration]) => registration.podId === podId) + .map(([registrationId]) => registrationId); + await Promise.all(registrationIds.map((id) => this.unregisterRegistration(id))); + } + + async close(): Promise { + await Promise.all([...this.registrations.keys()].map((id) => this.unregisterRegistration(id))); + } +} diff --git a/packages/stack/src/fleet/EdgeProxy.unit.test.ts b/packages/stack/src/fleet/EdgeProxy.unit.test.ts new file mode 100644 index 0000000000..1b3dfcc43f --- /dev/null +++ b/packages/stack/src/fleet/EdgeProxy.unit.test.ts @@ -0,0 +1,294 @@ +import { type AddressInfo, connect, createServer, type Server } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { EdgeProxy, MAX_PREWAKE_BUFFER_BYTES } from "./EdgeProxy.ts"; + +function echoServer(): Promise<{ server: Server; port: number }> { + return new Promise((resolve) => { + const server = createServer((sock) => sock.pipe(sock)); + server.listen(0, "127.0.0.1", () => + resolve({ server, port: (server.address() as AddressInfo).port }), + ); + }); +} + +function freePort(): Promise { + return new Promise((resolve) => { + const s = createServer(); + s.listen(0, "127.0.0.1", () => { + const port = (s.address() as AddressInfo).port; + s.close(() => resolve(port)); + }); + }); +} + +describe("EdgeProxy", () => { + const proxies: EdgeProxy[] = []; + afterEach(async () => { + for (const p of proxies.splice(0)) await p.close(); + }); + + it("wakes on first connection and splices bytes both ways", async () => { + const { server, port: upstreamPort } = await echoServer(); + let wakes = 0; + const proxy = new EdgeProxy(); + proxies.push(proxy); + const listenPort = await freePort(); + await proxy.register("pod-a", "database", listenPort, async () => { + wakes += 1; + return { host: "127.0.0.1", port: upstreamPort }; + }); + + const reply = await new Promise((resolve, reject) => { + const sock = connect(listenPort, "127.0.0.1", () => sock.write("ping")); + sock.on("data", (d) => { + resolve(d.toString()); + sock.end(); + }); + sock.on("error", reject); + }); + expect(reply).toBe("ping"); + expect(wakes).toBe(1); + server.close(); + }); + + it("tracks open connections and reports activity", async () => { + const { server, port: upstreamPort } = await echoServer(); + const events: string[] = []; + const proxy = new EdgeProxy({ + onActivity: (id, ev) => events.push(`${id}:${ev}`), + }); + proxies.push(proxy); + const listenPort = await freePort(); + await proxy.register("pod-b", "database", listenPort, async () => ({ + host: "127.0.0.1", + port: upstreamPort, + })); + + await new Promise((resolve) => { + const sock = connect(listenPort, "127.0.0.1", () => sock.write("x")); + sock.on("data", () => sock.end()); + sock.on("close", () => resolve()); + }); + await new Promise((r) => setTimeout(r, 50)); + expect(events).toContain("pod-b:connect"); + expect(events).toContain("pod-b:data"); + expect(events).toContain("pod-b:disconnect"); + expect(proxy.openConnections("pod-b")).toBe(0); + server.close(); + }); + + it("destroys the client socket when wake() rejects, and stays usable afterward", async () => { + const proxy = new EdgeProxy(); + proxies.push(proxy); + const listenPort = await freePort(); + let attempts = 0; + await proxy.register("pod-c", "database", listenPort, async () => { + attempts += 1; + if (attempts === 1) throw new Error("wake failed"); + return { host: "127.0.0.1", port: await freePort() }; + }); + + // First connection: wake() rejects, socket should close/error without + // taking down the process (no unhandled rejection) and without wedging + // the listener. + await new Promise((resolve) => { + const sock = connect(listenPort, "127.0.0.1"); + let settled = false; + const finish = () => { + if (!settled) { + settled = true; + resolve(); + } + }; + sock.on("error", finish); + sock.on("close", finish); + }); + + // Second connection: proxy must still be usable. Use a real echo server + // for the second wake so we can prove the pipe still works. + const { server, port: upstreamPort } = await echoServer(); + await proxy.unregister("pod-c"); + await proxy.register("pod-c", "database", listenPort, async () => ({ + host: "127.0.0.1", + port: upstreamPort, + })); + + const reply = await new Promise((resolve, reject) => { + const sock = connect(listenPort, "127.0.0.1", () => sock.write("hello")); + sock.on("data", (d) => { + resolve(d.toString()); + sock.end(); + }); + sock.on("error", reject); + }); + expect(reply).toBe("hello"); + server.close(); + }); + + it("handles two concurrent connections to the same suspended pod, both succeed", async () => { + const { server, port: upstreamPort } = await echoServer(); + let wakeCalls = 0; + const proxy = new EdgeProxy(); + proxies.push(proxy); + const listenPort = await freePort(); + await proxy.register("pod-d", "database", listenPort, async () => { + wakeCalls += 1; + // Simulate a suspended pod that takes a bit to wake. + await new Promise((r) => setTimeout(r, 20)); + return { host: "127.0.0.1", port: upstreamPort }; + }); + + const connectAndEcho = (payload: string) => + new Promise((resolve, reject) => { + const sock = connect(listenPort, "127.0.0.1", () => sock.write(payload)); + sock.on("data", (d) => { + resolve(d.toString()); + sock.end(); + }); + sock.on("error", reject); + }); + + const [replyA, replyB] = await Promise.all([connectAndEcho("aaa"), connectAndEcho("bbb")]); + expect(replyA).toBe("aaa"); + expect(replyB).toBe("bbb"); + // Contract: wake() may be called once or twice; dedup happens at the + // fleet layer, not in EdgeProxy. + expect(wakeCalls).toBeGreaterThanOrEqual(1); + expect(wakeCalls).toBeLessThanOrEqual(2); + server.close(); + }); + + it("does not connect a dangling backend when unregistered while wake() is in flight", async () => { + const proxy = new EdgeProxy(); + proxies.push(proxy); + const listenPort = await freePort(); + const upstreamPort = await freePort(); // nothing listening here + + await proxy.register("pod-e", "database", listenPort, async () => { + await new Promise((r) => setTimeout(r, 100)); + return { host: "127.0.0.1", port: upstreamPort }; + }); + + await new Promise((resolve) => { + const sock = connect(listenPort, "127.0.0.1", () => sock.write("x")); + sock.on("error", () => resolve()); + sock.on("close", () => resolve()); + setTimeout(() => { + proxy.unregister("pod-e").catch(() => {}); + }, 20); + }); + + // Wait past wake()'s resolution point; if EdgeProxy tried to connect a + // backend for the now-destroyed client, it would leak a socket/listener. + await new Promise((r) => setTimeout(r, 150)); + expect(proxy.openConnections("pod-e")).toBe(0); + }); + + it("destroys the client if it floods the pre-wake buffer past the cap, and stays usable afterward", async () => { + const proxy = new EdgeProxy(); + proxies.push(proxy); + const listenPort = await freePort(); + + let unhandledRejection: unknown; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejection = reason; + }; + process.on("unhandledRejection", onUnhandledRejection); + + try { + await proxy.register("pod-f", "database", listenPort, async () => { + // Slow wake gives the flooding client plenty of time to exceed the cap. + await new Promise((r) => setTimeout(r, 200)); + return { host: "127.0.0.1", port: await freePort() }; + }); + + const clientDestroyed = await new Promise((resolve) => { + const sock = connect(listenPort, "127.0.0.1", () => { + // A single write just over the cap: this floods immediately and + // reflects that the cap must trigger regardless of how the + // client chooses to chunk its writes. + sock.write(Buffer.alloc(MAX_PREWAKE_BUFFER_BYTES + 64 * 1024, "a")); + }); + sock.on("error", () => resolve(true)); + sock.on("close", () => resolve(sock.destroyed)); + }); + expect(clientDestroyed).toBe(true); + + // Give any lingering wake() resolution a chance to run before we assert + // there was no unhandled rejection. + await new Promise((r) => setTimeout(r, 250)); + expect(unhandledRejection).toBeUndefined(); + + // Proxy must still be usable for a subsequent normal connection. + const { server, port: upstreamPort } = await echoServer(); + await proxy.unregister("pod-f"); + await proxy.register("pod-f", "database", listenPort, async () => ({ + host: "127.0.0.1", + port: upstreamPort, + })); + + const reply = await new Promise((resolve, reject) => { + const sock = connect(listenPort, "127.0.0.1", () => sock.write("hello")); + sock.on("data", (d) => { + resolve(d.toString()); + sock.end(); + }); + sock.on("error", reject); + }); + expect(reply).toBe("hello"); + server.close(); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); + + it("destroys the client without an unhandled rejection when wake() resolves with a garbage upstream", async () => { + const proxy = new EdgeProxy(); + proxies.push(proxy); + const listenPort = await freePort(); + + let unhandledRejection: unknown; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejection = reason; + }; + process.on("unhandledRejection", onUnhandledRejection); + + try { + await proxy.register("pod-g", "database", listenPort, async () => ({ + host: "127.0.0.1", + port: -1, + })); + + const clientDestroyed = await new Promise((resolve) => { + const sock = connect(listenPort, "127.0.0.1", () => sock.write("x")); + sock.on("error", () => resolve(true)); + sock.on("close", () => resolve(sock.destroyed)); + }); + expect(clientDestroyed).toBe(true); + + await new Promise((r) => setTimeout(r, 50)); + expect(unhandledRejection).toBeUndefined(); + + // Proxy must still be usable for a subsequent normal connection. + const { server, port: upstreamPort } = await echoServer(); + await proxy.unregister("pod-g"); + await proxy.register("pod-g", "database", listenPort, async () => ({ + host: "127.0.0.1", + port: upstreamPort, + })); + + const reply = await new Promise((resolve, reject) => { + const sock = connect(listenPort, "127.0.0.1", () => sock.write("hello")); + sock.on("data", (d) => { + resolve(d.toString()); + sock.end(); + }); + sock.on("error", reject); + }); + expect(reply).toBe("hello"); + server.close(); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); +}); diff --git a/packages/stack/src/fleet/Fleet.integration.test.ts b/packages/stack/src/fleet/Fleet.integration.test.ts new file mode 100644 index 0000000000..ba0a44c915 --- /dev/null +++ b/packages/stack/src/fleet/Fleet.integration.test.ts @@ -0,0 +1,96 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createFleet } from "./Fleet.ts"; + +const PG_VERSION = "17.6.1.143"; + +async function query(dbUrl: string, sql: string): Promise { + // Minimal client via Bun's built-in postgres support; this suite runs under Bun. + const { SQL } = await import("bun"); + const db = new SQL(dbUrl); + const rows = await db.unsafe(sql); + await db.close(); + return JSON.stringify(rows); +} + +describe.skipIf(!process.env.FLEET_PG_TESTS)("Fleet", () => { + it("wake-on-connect, suspend-on-idle, fork", async () => { + const root = await mkdtemp(join(tmpdir(), "fleet-e2e-")); + await using fleet = await createFleet({ root, idleMs: 2000 }); + + const a = await fleet.createPod({ + id: "a", + versions: { postgres: PG_VERSION }, + services: [], + warmTemplate: false, + start: false, + }); + expect(a.state).toBe("suspended"); + + // First connection wakes the pod transparently. + await query(a.dbUrl, "create table t(x int); insert into t values (1)"); + const warm = (await fleet.listPods()).find((p) => p.id === "a"); + expect(warm?.state).toBe("warm"); + + // Idle out (no connections) -> suspended. + await new Promise((r) => setTimeout(r, 4000)); + const idle = (await fleet.listPods()).find((p) => p.id === "a"); + expect(idle?.state).toBe("suspended"); + + // Wake again on the SAME dbUrl; data survived suspend. + expect(await query(a.dbUrl, "select x from t")).toContain("1"); + + // Fork inherits data, diverges independently. + const b = await fleet.forkPod("a", "b"); + const sourceAfterFork = (await fleet.listPods()).find((p) => p.id === "a"); + expect(sourceAfterFork?.state).toBe("warm"); + await query(b.dbUrl, "insert into t values (2)"); + expect(await query(a.dbUrl, "select count(*)::int as n from t")).toContain("1"); + expect(await query(b.dbUrl, "select count(*)::int as n from t")).toContain("2"); + }, 600_000); + + it("serializes concurrent suspend/wake without corrupting the pod", async () => { + const root = await mkdtemp(join(tmpdir(), "fleet-e2e-")); + await using fleet = await createFleet({ root, idleMs: 60_000 }); + + const a = await fleet.createPod({ + id: "a", + versions: { postgres: PG_VERSION }, + services: [], + warmTemplate: false, + }); + await query(a.dbUrl, "create table t(x int); insert into t values (1)"); + + // Wake explicitly, then hammer it with interleaved suspend calls and + // queries racing against each other. Queries may transiently fail as + // connections drop out from under them mid-suspend — that's expected + // and NOT asserted against — but the pod must never crash the fleet + // process, and per-pod lifecycle ops must never interleave badly enough + // to corrupt the data dir or leave the pod stuck in a bad state. + await fleet.wake("a"); + + const attempts = await Promise.allSettled([ + fleet.suspend("a"), + query(a.dbUrl, "select x from t"), + fleet.suspend("a"), + query(a.dbUrl, "select x from t"), + fleet.wake("a"), + fleet.suspend("a"), + query(a.dbUrl, "select x from t"), + ]); + // No assertions on individual outcomes: the point is none of this threw + // an unhandled exception past allSettled (i.e. the fleet process didn't + // crash) and that whatever state we land in is still usable below. + expect(attempts.length).toBe(7); + + // Whatever transient state the interleaving left the pod in, a final + // query must succeed and see the original data intact (wake-on-connect + // recovers a suspended pod; a warm pod just answers directly). + expect(await query(a.dbUrl, "select x from t")).toContain("1"); + + const final = (await fleet.listPods()).find((p) => p.id === "a"); + expect(["warm", "suspended"]).toContain(final?.state); + }, 600_000); +}); diff --git a/packages/stack/src/fleet/Fleet.ts b/packages/stack/src/fleet/Fleet.ts new file mode 100644 index 0000000000..711ce6b905 --- /dev/null +++ b/packages/stack/src/fleet/Fleet.ts @@ -0,0 +1,543 @@ +import { randomUUID } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + DEFAULT_VERSIONS, + STACK_SERVICE_NAMES, + postgresConnectionUrl, + resolvePostgresPassword, + type FunctionsConfig, + type StackHandle, + type VersionManifest, +} from "@supabase/stack"; +import { + configureExtensionPreload, + createProvisionedStack, + type ProvisionedServiceName, +} from "@supabase/stack/provisioned"; +import { EdgeProxy } from "./EdgeProxy.ts"; +import { acquireFleetLock } from "./fleetLock.ts"; +import { IdleMonitor } from "./IdleMonitor.ts"; +import type { PodManifest } from "./PodManifest.ts"; +import { PodLock } from "./podLock.ts"; +import { PodRegistry } from "./PodRegistry.ts"; +import { PortRegistry } from "./PortRegistry.ts"; +import { Provisioner } from "./Provisioner.ts"; +import { reapStalePostmaster } from "./reapStalePostmaster.ts"; +import { TemplateStore } from "./TemplateStore.ts"; + +export interface FleetOptions { + readonly root?: string; + readonly idleMs?: number; +} + +export interface CreatePodOptions { + readonly id?: string; + readonly services?: ReadonlyArray; + readonly versions?: Partial; + /** Pre-migrate enabled services into the cached template. Default: true. */ + readonly warmTemplate?: boolean; + /** Start Postgres before returning. Other enabled services stay lazy. Default: true. */ + readonly start?: boolean; + readonly projectDir?: string; + readonly functions?: FunctionsConfig | false; +} + +export type PodState = "suspended" | "waking" | "warm" | "suspending"; + +export interface PodStatus { + readonly id: string; + readonly state: PodState; + readonly url: string; + readonly dbUrl: string; + readonly publishableKey: string; + readonly secretKey: string; +} + +export interface FleetHandle extends AsyncDisposable { + createPod(opts?: CreatePodOptions): Promise; + destroyPod(id: string): Promise; + resetPod(id: string): Promise; + forkPod(sourceId: string, newId: string): Promise; + wake(id: string): Promise; + suspend(id: string): Promise; + ensureExtensionPreload(id: string, extension: string): Promise; + listPods(): Promise>; + dispose(): Promise; +} + +interface WarmPod { + readonly stack: StackHandle; + readonly internalDbPort: number; + readonly internalApiPort: number; +} + +/** + * Public facade tying together TemplateStore/PodRegistry/PortRegistry/Provisioner + * (pod lifecycle + storage) with EdgeProxy/IdleMonitor (wake-on-connect, + * suspend-on-idle) to host warm pods as in-process `@supabase/stack` StackHandles. + * + * - Every pod's external `dbPort` is registered on the EdgeProxy the moment the + * pod is known (created, forked, or discovered at startup) and never changes + * again; suspended pods still "answer" that port because the proxy itself + * owns the listener and defers to `wake()` on first connection. + * - A pod's postgres process (and any other lazily-started stack services) + * only exists while the pod is "warm": `wakeUpstream` creates an in-process + * `StackHandle` on demand and tears it down again in `suspend`. + * - Concurrent wakes of the same pod are deduped via `wakesInFlight`, since + * EdgeProxy may invoke `wake()` once per connection. + * - Every operation that touches a pod's live processes or on-disk data dir + * (the body of a wake, the body of `suspend`, and the process/data-dir + * affecting parts of `destroyPod`/`resetPod`/`forkPod`) runs inside + * `podLocks.withLock(id, ...)`, a per-pod FIFO chain (see `podLock.ts`). + * This prevents e.g. a wake racing an in-flight suspend from calling + * `createStack` against a data dir whose postmaster is still shutting + * down, or `destroyPod`/`resetPod` deleting a data dir out from under a + * wake that's still in `wakesInFlight` (and thus not yet visible in + * `warm`). Wake dedup via `wakesInFlight` deliberately stays OUTSIDE the + * lock so concurrent connections still share a single wake; only the + * shared wake body itself acquires the lock. + * - Startup reconciliation keys off postgres's own + * `/postmaster.pid` (see `reapStalePostmaster.ts`) since + * process-compose/`createStack` spawn postgres `detached: true` — its own + * process group, not the daemon's — so killing `-daemonPid` would miss it + * entirely. Reaping the postmaster's process group is the final verification + * after Stack performs strict cleanup of every service it owns. This is a + * kill-then-suspend policy, not adoption: the long-term goal is to + * adopt still-live pods across daemon restarts, but that requires + * reattaching the in-process StackHandle to an externally running set of + * processes, which isn't supported yet. Killing and letting the next + * connection re-wake the pod is acceptable because pod data is disposable + * and wake is fast. + */ +export async function createFleet(opts: FleetOptions = {}): Promise { + const root = opts.root ?? join(homedir(), ".supabase"); + const idleMs = opts.idleMs ?? 5 * 60_000; + // Must match packages/stack/src/services/postgres.ts: the template build stores + // this password in the data dir, and pod connection URLs need to use the same + // value when exposing the suspended/warm pod. + const postgresPassword = resolvePostgresPassword(); + + // Single daemon per root, acquired BEFORE any pod state is touched: the + // startup reconciliation below kills postmasters it finds under pod dirs, + // which would include a live sibling daemon's warm pods. + const fleetLock = await acquireFleetLock(join(root, "fleet.lock")); + + const templates = new TemplateStore(join(root, "templates"), postgresPassword); + const pods = new PodRegistry(join(root, "pods")); + let ports: PortRegistry; + try { + ports = await PortRegistry.load(join(root, "fleet-state.json")); + } catch (error) { + const release = await Promise.allSettled([fleetLock.release()]); + throwWithCleanup(error, release, "Failed to initialize and release Fleet lock"); + } + const provisioner = new Provisioner({ templates, pods, ports, postgresPassword }); + + const states = new Map(); + const warm = new Map(); + const wakesInFlight = new Map>(); + const operationsInFlight = new Set>(); + const podLocks = new PodLock(); + let disposed = false; + let disposeInFlight: Promise | undefined; + + function throwWithCleanup( + primary: unknown, + results: ReadonlyArray>, + message: string, + ): never { + const cleanupErrors = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (cleanupErrors.length === 0) throw primary; + throw new AggregateError([primary, ...cleanupErrors], message); + } + + function assertCleanup( + results: ReadonlyArray>, + message: string, + ): void { + const errors = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, message); + } + + function runOperation(body: () => Promise): Promise { + if (disposed) return Promise.reject(new Error("fleet is disposed")); + let operation: Promise; + try { + operation = Promise.resolve(body()); + } catch (error) { + operation = Promise.reject(error); + } + operationsInFlight.add(operation); + void operation.then( + () => operationsInFlight.delete(operation), + () => operationsInFlight.delete(operation), + ); + return operation; + } + + const monitor = new IdleMonitor({ + idleMs, + onIdle: (podId) => { + void runOperation(() => idleSuspend(podId)).catch(() => {}); + }, + }); + + const proxy = new EdgeProxy({ + onActivity: (podId, _event, openConnections) => { + monitor.recordActivity(podId, openConnections); + }, + }); + + const dbUrl = (manifest: PodManifest): string => + postgresConnectionUrl({ + user: "postgres", + password: manifest.postgresPassword, + host: "127.0.0.1", + port: manifest.dbPort, + database: "postgres", + }); + + async function withPodLocks(ids: ReadonlyArray, body: () => Promise): Promise { + const ordered = [...new Set(ids)].sort((a, b) => a.localeCompare(b)); + const acquire = (index: number): Promise => + index >= ordered.length + ? body() + : podLocks.withLock(ordered[index]!, () => acquire(index + 1)); + return acquire(0); + } + + async function rollbackProvisionedPod(id: string): Promise { + const results = await Promise.allSettled([proxy.unregister(id), provisioner.destroy(id)]); + states.delete(id); + assertCleanup(results, `Failed to roll back pod ${id}`); + } + + /** Starts a pod while its lifecycle lock is already held. */ + async function wakeUpstreamLocked(id: string): Promise { + const lockedExisting = warm.get(id); + if (lockedExisting !== undefined) { + if (states.get(id) === "warm") { + return lockedExisting; + } + throw new Error(`pod ${id} has not finished suspending; retry the suspend operation`); + } + const manifest = await pods.read(id); + if (manifest === undefined) throw new Error(`unknown pod: ${id}`); + states.set(id, "waking"); + let stack: StackHandle | undefined; + let internalDbPort = 0; + let internalApiPort = 0; + try { + stack = await createProvisionedStack({ + stackRoot: join(pods.podDir(id), "stack"), + dataDir: pods.dataDir(id), + postgresPassword: manifest.postgresPassword, + jwtSecret: manifest.jwtSecret, + publishableKey: manifest.publishableKey, + secretKey: manifest.secretKey, + versions: manifest.versions, + services: manifest.services, + projectDir: manifest.projectDir ?? pods.podDir(id), + functions: manifest.functions, + }); + internalDbPort = Number(new URL(stack.dbUrl).port); + internalApiPort = Number(new URL(stack.url).port); + if (!Number.isInteger(internalDbPort) || internalDbPort <= 0) { + throw new Error(`stack returned an invalid database URL for pod ${id}: ${stack.dbUrl}`); + } + if (!Number.isInteger(internalApiPort) || internalApiPort <= 0) { + throw new Error(`stack returned an invalid API URL for pod ${id}: ${stack.url}`); + } + const pod = { stack, internalDbPort, internalApiPort }; + warm.set(id, pod); + states.set(id, "warm"); + monitor.track(id); + monitor.recordActivity(id, proxy.openConnections(id)); + return pod; + } catch (err) { + const cleanup = await Promise.allSettled(stack === undefined ? [] : [stack.dispose()]); + if (cleanup.some((result) => result.status === "rejected") && stack !== undefined) { + // Preserve the failed runtime so an explicit suspend/dispose can retry strict cleanup. + warm.set(id, { stack, internalDbPort, internalApiPort }); + states.set(id, "suspending"); + throwWithCleanup(err, cleanup, `Failed to wake and clean up pod ${id}`); + } + states.set(id, "suspended"); + throw err; + } + } + + async function wakeUpstream(id: string): Promise { + if (disposed) throw new Error("fleet is disposed"); + const existing = warm.get(id); + if (existing && states.get(id) === "warm") { + return existing; + } + // Dedup deliberately stays OUTSIDE podLocks: EdgeProxy may invoke wake() + // once per connection, and every such caller should share the SAME wake + // (and thus the same lock acquisition), not each queue up its own. + const inFlight = wakesInFlight.get(id); + if (inFlight) return inFlight; + const p = podLocks.withLock(id, () => wakeUpstreamLocked(id)); + const tracked = p.finally(() => { + wakesInFlight.delete(id); + }); + wakesInFlight.set(id, tracked); + return tracked; + } + + async function registerEdge(manifest: PodManifest): Promise { + states.set(manifest.id, "suspended"); + try { + await proxy.register(manifest.id, "database", manifest.dbPort, async () => { + const pod = await wakeUpstream(manifest.id); + return { host: "127.0.0.1", port: pod.internalDbPort }; + }); + await proxy.register(manifest.id, "api", manifest.apiPort, async () => { + const pod = await wakeUpstream(manifest.id); + return { host: "127.0.0.1", port: pod.internalApiPort }; + }); + } catch (error) { + const cleanup = await Promise.allSettled([proxy.unregister(manifest.id)]); + throwWithCleanup(error, cleanup, `Failed to register and roll back pod ${manifest.id}`); + } + } + + async function suspend(id: string): Promise { + // Full body runs inside the per-pod lock so a suspend can never + // interleave with a concurrent wake, destroy, reset, or fork touching + // the same pod's process/data dir. Note this is a fresh, non-re-entrant + // acquisition: callers that already hold the lock for `id` (forkPod's + // source-pod lock) call the LOCKED body directly instead of this + // function — see `suspendLocked` usage below. + return podLocks.withLock(id, () => suspendLocked(id)); + } + + // Idle-triggered suspend: unlike an explicit suspend, this must yield to + // clients that connected between the idle timer firing (which untracks the + // pod, making their recordActivity a no-op) and this body acquiring the pod + // lock. Those connections were already accepted by the proxy — and any + // connection accepted AFTER the openConnections check below can no longer + // grab the warm upstream, because the check and `warm.delete` in + // suspendLocked run in the same synchronous block. + async function idleSuspend(id: string): Promise { + return podLocks.withLock(id, async () => { + if (proxy.openConnections(id) > 0 && warm.has(id)) { + monitor.track(id); + monitor.recordActivity(id, proxy.openConnections(id)); + return; + } + await suspendLocked(id); + }); + } + + async function suspendLocked(id: string): Promise { + const pod = warm.get(id); + if (!pod) return; + states.set(id, "suspending"); + monitor.untrack(id); + await pod.stack.dispose(); + // StackHandle.dispose() is strict. Reaping the postmaster is an additional + // fleet-level verification because a suspended pod must own no processes. + await reapStalePostmaster(pods.dataDir(id)); + warm.delete(id); + states.set(id, "suspended"); + } + + async function status(manifest: PodManifest): Promise { + return { + id: manifest.id, + state: states.get(manifest.id) ?? "suspended", + url: `http://127.0.0.1:${manifest.apiPort}`, + dbUrl: dbUrl(manifest), + publishableKey: manifest.publishableKey, + secretKey: manifest.secretKey, + }; + } + + // Startup reconciliation uses kill-then-suspend, not adoption + // (see class doc above). The kill decision keys off postgres's own ground + // truth, `/postmaster.pid`, whose first + // line is the postmaster's pid; the postmaster is always its own + // process-group leader, so `reapStalePostmaster` can reliably signal the + // whole tree via `-pid`. The pod port registry is reconciled + // to exactly the valid manifests on disk so stale allocations from deleted + // or skipped pods are pruned during startup. + try { + // Reap over EVERY pod directory on disk — not just parseable manifests. A + // corrupt pod.json is exactly the case where the previous daemon may have + // died uncleanly, and skipping it would leave its stale postmaster bound + // to ports that the reconcile below is about to prune and hand out again. + for (const id of await pods.listIds()) { + await reapStalePostmaster(pods.dataDir(id)); + } + const manifests = await pods.list(); + await ports.reconcile( + new Map( + manifests.map((manifest) => [ + manifest.id, + { dbPort: manifest.dbPort, apiPort: manifest.apiPort }, + ]), + ), + ); + for (const manifest of manifests) { + await registerEdge(manifest); + } + } catch (err) { + const cleanup = await Promise.allSettled([proxy.close(), fleetLock.release()]); + throwWithCleanup(err, cleanup, "Failed to initialize and clean up Fleet"); + } + + const handle: FleetHandle = { + createPod(opts = {}) { + const id = opts.id ?? randomUUID(); + return runOperation(() => + podLocks.withLock(id, async () => { + const manifest = await provisioner.create({ + id, + versions: { ...DEFAULT_VERSIONS, ...opts.versions }, + services: opts.services ?? STACK_SERVICE_NAMES, + warm: opts.warmTemplate ?? true, + projectDir: opts.projectDir, + functions: opts.functions, + }); + try { + await registerEdge(manifest); + if (opts.start !== false) { + await wakeUpstreamLocked(manifest.id); + } + return status(manifest); + } catch (err) { + const cleanup = await Promise.allSettled([rollbackProvisionedPod(manifest.id)]); + throwWithCleanup(err, cleanup, `Failed to create and roll back pod ${manifest.id}`); + } + }), + ); + }, + destroyPod(id) { + // suspend (tears down live processes) and provisioner.destroy (deletes + // the data dir) run as ONE lock acquisition so a wake that's still + // mid-flight (registered in wakesInFlight, not yet in `warm`) can't + // slip in between them and recreate a stack against a dir we're about + // to delete. + return runOperation(() => + podLocks.withLock(id, async () => { + await suspendLocked(id); + await proxy.unregister(id); + await provisioner.destroy(id); + states.delete(id); + }), + ); + }, + resetPod(id) { + return runOperation(() => + podLocks.withLock(id, async () => { + const wasWarm = states.get(id) === "warm"; + await suspendLocked(id); + await provisioner.reset(id); + if (wasWarm) await wakeUpstreamLocked(id); + }), + ); + }, + forkPod(sourceId, newId) { + // Lock the SOURCE pod around suspend + the fork's clone of its data + // dir (provisioner.fork reads sourceId's data dir), and lock the TARGET + // id so concurrent creates/forks cannot delete each other's results. + return runOperation(async () => { + if (sourceId === newId) throw new Error(`pod already exists: ${newId}`); + const manifest = await withPodLocks([sourceId, newId], async () => { + const sourceWasWarm = states.get(sourceId) === "warm"; + await suspendLocked(sourceId); + const forked = await provisioner.fork(sourceId, newId); + try { + // Forking needs an offline snapshot, but it should not change the + // source pod's observable lifecycle state after the clone is done. + if (sourceWasWarm) await wakeUpstreamLocked(sourceId); + await registerEdge(forked); + await wakeUpstreamLocked(forked.id); + return forked; + } catch (err) { + const cleanup = await Promise.allSettled([rollbackProvisionedPod(forked.id)]); + throwWithCleanup(err, cleanup, `Failed to fork and roll back pod ${forked.id}`); + } + }); + return status(manifest); + }); + }, + wake(id) { + return runOperation(async () => { + await wakeUpstream(id); + }); + }, + suspend(id) { + return runOperation(() => suspend(id)); + }, + // This configures preload state; it does not run CREATE EXTENSION. Preload-required + // libraries are persisted and postgres is restarted when warm. Other + // extensions require no orchestration and are therefore a no-op here. + ensureExtensionPreload(id, extension) { + return runOperation(() => + podLocks.withLock(id, async () => { + const pod = warm.get(id); + if (pod) { + await pod.stack.ensureExtensionPreload(extension); + return; + } + const manifest = await pods.read(id); + if (manifest === undefined) throw new Error(`unknown pod: ${id}`); + await configureExtensionPreload(pods.dataDir(id), extension); + }), + ); + }, + listPods() { + return runOperation(async () => { + const manifests = await pods.list(); + return Promise.all(manifests.map((m) => status(m))); + }); + }, + dispose() { + if (disposeInFlight !== undefined) return disposeInFlight; + disposed = true; + const attempt = (async () => { + const cleanupErrors: unknown[] = []; + await Promise.allSettled(operationsInFlight); + try { + await proxy.close(); + } catch (error) { + cleanupErrors.push(error); + } + await Promise.allSettled(wakesInFlight.values()); + const suspendResults = await Promise.allSettled( + [...warm.keys()].map((id) => podLocks.withLock(id, () => suspendLocked(id))), + ); + for (const result of suspendResults) { + if (result.status === "rejected") cleanupErrors.push(result.reason); + } + if (cleanupErrors.length === 1) throw cleanupErrors[0]; + if (cleanupErrors.length > 1) { + throw new AggregateError(cleanupErrors, "failed to dispose fleet cleanly"); + } + // Releasing the root lock certifies that this daemon no longer owns + // listeners or pod processes. Keep it held when cleanup fails so a + // second daemon cannot reconcile the same root concurrently; callers + // may retry dispose() after the transient failure is resolved. + await fleetLock.release(); + })(); + disposeInFlight = attempt.catch((error: unknown) => { + disposeInFlight = undefined; + throw error; + }); + return disposeInFlight; + }, + async [Symbol.asyncDispose]() { + await handle.dispose(); + }, + }; + return handle; +} diff --git a/packages/stack/src/fleet/Fleet.unit.test.ts b/packages/stack/src/fleet/Fleet.unit.test.ts new file mode 100644 index 0000000000..4a505cd119 --- /dev/null +++ b/packages/stack/src/fleet/Fleet.unit.test.ts @@ -0,0 +1,141 @@ +import { createServer } from "node:net"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createFleet } from "./Fleet.ts"; +import { PodRegistry } from "./PodRegistry.ts"; +import type { PodManifest } from "./PodManifest.ts"; + +function tryListen(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.on("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); +} + +// Manifest ports must sit in the fleet's public range (55000+). The OS +// ephemeral range may sit entirely below it, so probe candidates directly. +async function freeFleetPort(): Promise { + const start = 55000 + Math.floor(Math.random() * 8000); + for (let offset = 0; offset < 200; offset += 1) { + const candidate = start + offset; + if ((await tryListen(candidate)) && (await tryListen(candidate + 1))) return candidate; + } + throw new Error("could not find a free port in the fleet public range"); +} + +function expectPortAvailable(port: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.on("error", reject); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve()); + }); + }); +} + +function manifest(id: string, dbPort: number): PodManifest { + return { + id, + versions: { postgres: "17.6.1.143" }, + services: [], + flags: { supautils: false }, + warm: false, + dbPort, + apiPort: dbPort + 1, + postgresPassword: "postgres", + jwtSecret: "01234567890123456789012345678901", + publishableKey: "sb_publishable_test", + secretKey: "sb_secret_test", + createdAt: "2026-07-08T00:00:00.000Z", + }; +} + +describe("createFleet", () => { + it("refuses to start a second daemon for the same fleet root", async () => { + const root = await mkdtemp(join(tmpdir(), "fleet-unit-")); + try { + const fleet = await createFleet({ root }); + try { + await expect(createFleet({ root })).rejects.toThrow(/already owns/); + } finally { + await fleet.dispose(); + } + // Once the owner releases the lock, the root is startable again. + const second = await createFleet({ root }); + await second.dispose(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("takes over a stale fleet lock left by a dead daemon", async () => { + const root = await mkdtemp(join(tmpdir(), "fleet-unit-")); + try { + // No live process has this pid (well above typical pid ranges). + await writeFile(join(root, "fleet.lock"), "999999"); + const fleet = await createFleet({ root }); + await fleet.dispose(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("releases the fleet lock when registry initialization fails", async () => { + if (typeof process.getuid === "function" && process.getuid() === 0) return; + const root = await mkdtemp(join(tmpdir(), "fleet-unit-")); + const stateFile = join(root, "fleet-state.json"); + try { + await writeFile(stateFile, JSON.stringify({ pods: {} })); + await chmod(stateFile, 0o000); + await expect(createFleet({ root })).rejects.toThrow(); + + await chmod(stateFile, 0o600); + const fleet = await createFleet({ root }); + await fleet.dispose(); + } finally { + await chmod(stateFile, 0o600).catch(() => {}); + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects every operation after disposal releases the fleet lock", async () => { + const root = await mkdtemp(join(tmpdir(), "fleet-unit-")); + try { + const fleet = await createFleet({ root }); + await fleet.dispose(); + + await expect(fleet.listPods()).rejects.toThrow("fleet is disposed"); + await expect(fleet.wake("pod-a")).rejects.toThrow("fleet is disposed"); + await expect(fleet.suspend("pod-a")).rejects.toThrow("fleet is disposed"); + await expect(fleet.destroyPod("pod-a")).rejects.toThrow("fleet is disposed"); + await expect(fleet.resetPod("pod-a")).rejects.toThrow("fleet is disposed"); + await expect(fleet.forkPod("pod-a", "pod-b")).rejects.toThrow("fleet is disposed"); + await expect(fleet.ensureExtensionPreload("pod-a", "pg_cron")).rejects.toThrow( + "fleet is disposed", + ); + await expect(fleet.createPod({ id: "pod-a" })).rejects.toThrow("fleet is disposed"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("closes registered edge listeners when startup reconciliation fails", async () => { + const root = await mkdtemp(join(tmpdir(), "fleet-unit-")); + try { + const pods = new PodRegistry(join(root, "pods")); + const dbPort = await freeFleetPort(); + await pods.write(manifest("pod-a", dbPort)); + await pods.write(manifest("pod-b", dbPort)); + + await expect(createFleet({ root })).rejects.toThrow(/port already assigned/); + await expect(expectPortAvailable(dbPort)).resolves.toBeUndefined(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/stack/src/fleet/IdleMonitor.ts b/packages/stack/src/fleet/IdleMonitor.ts new file mode 100644 index 0000000000..bbd5b4ca45 --- /dev/null +++ b/packages/stack/src/fleet/IdleMonitor.ts @@ -0,0 +1,69 @@ +interface Tracked { + timer: ReturnType | undefined; + openConnections: number; +} + +/** + * Connection-aware idle countdown: fires `onIdle(podId)` once a tracked pod + * has no open connections and no activity for `idleMs`. + * + * Design assumptions: + * - **Open connections hold the pod warm indefinitely.** While + * `openConnections > 0` (as last reported via `recordActivity`) the + * countdown never starts, no matter how much time passes. + * - **The countdown restarts on the last connection closing or on any + * activity.** Both are reported via `recordActivity`; each call re-arms + * the timer from `idleMs`. + * - **`onIdle` fires at most once per warm period.** When the timer fires, + * the pod is untracked before `onIdle` runs; a subsequent `track()` is + * required to re-arm it. + * - **`track()` on an already-tracked pod is a no-op.** It does not reset + * an in-flight countdown; callers that want a reset should use + * `recordActivity` instead. + */ +export class IdleMonitor { + private readonly tracked = new Map(); + + constructor( + private readonly opts: { + readonly idleMs: number; + readonly onIdle: (podId: string) => void; + }, + ) {} + + /** Start tracking a pod (e.g. on wake). No-op if already tracked. */ + track(podId: string): void { + if (!this.tracked.has(podId)) { + this.tracked.set(podId, { timer: undefined, openConnections: 0 }); + this.arm(podId); + } + } + + /** Stop tracking (on suspend/destroy). */ + untrack(podId: string): void { + const entry = this.tracked.get(podId); + if (entry?.timer) clearTimeout(entry.timer); + this.tracked.delete(podId); + } + + /** Wire to EdgeProxy events: any activity resets the timer; open connections hold it. */ + recordActivity(podId: string, openConnections: number): void { + const entry = this.tracked.get(podId); + if (!entry) return; + entry.openConnections = openConnections; + this.arm(podId); + } + + private arm(podId: string): void { + const entry = this.tracked.get(podId); + if (!entry) return; + if (entry.timer) clearTimeout(entry.timer); + entry.timer = undefined; + if (entry.openConnections > 0) return; // held warm by open connections + entry.timer = setTimeout(() => { + this.tracked.delete(podId); + this.opts.onIdle(podId); + }, this.opts.idleMs); + entry.timer.unref?.(); + } +} diff --git a/packages/stack/src/fleet/IdleMonitor.unit.test.ts b/packages/stack/src/fleet/IdleMonitor.unit.test.ts new file mode 100644 index 0000000000..6916091e80 --- /dev/null +++ b/packages/stack/src/fleet/IdleMonitor.unit.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IdleMonitor } from "./IdleMonitor.ts"; + +describe("IdleMonitor", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("fires onIdle after idleMs with no connections and no activity", () => { + const idled: string[] = []; + const mon = new IdleMonitor({ idleMs: 1000, onIdle: (id) => idled.push(id) }); + mon.track("a"); + mon.recordActivity("a", 0); + vi.advanceTimersByTime(999); + expect(idled).toEqual([]); + vi.advanceTimersByTime(2); + expect(idled).toEqual(["a"]); + }); + + it("open connections hold the pod warm indefinitely", () => { + const idled: string[] = []; + const mon = new IdleMonitor({ idleMs: 1000, onIdle: (id) => idled.push(id) }); + mon.track("a"); + mon.recordActivity("a", 1); // one open connection + vi.advanceTimersByTime(10_000); + expect(idled).toEqual([]); + mon.recordActivity("a", 0); // last connection closed + vi.advanceTimersByTime(1001); + expect(idled).toEqual(["a"]); + }); + + it("activity resets the countdown; untrack cancels", () => { + const idled: string[] = []; + const mon = new IdleMonitor({ idleMs: 1000, onIdle: (id) => idled.push(id) }); + mon.track("a"); + mon.recordActivity("a", 0); + vi.advanceTimersByTime(900); + mon.recordActivity("a", 0); // reset + vi.advanceTimersByTime(900); + expect(idled).toEqual([]); + mon.untrack("a"); + vi.advanceTimersByTime(5000); + expect(idled).toEqual([]); + }); + + it("onIdle fires at most once per warm period and untracks the pod", () => { + const idled: string[] = []; + const mon = new IdleMonitor({ idleMs: 1000, onIdle: (id) => idled.push(id) }); + mon.track("a"); + mon.recordActivity("a", 0); + vi.advanceTimersByTime(1000); + expect(idled).toEqual(["a"]); + // Timer already fired and pod was untracked; further advancing must not + // fire onIdle again. + vi.advanceTimersByTime(5000); + expect(idled).toEqual(["a"]); + }); + + it("track on an already-tracked pod does not reset an existing countdown", () => { + const idled: string[] = []; + const mon = new IdleMonitor({ idleMs: 1000, onIdle: (id) => idled.push(id) }); + mon.track("a"); + mon.recordActivity("a", 0); + vi.advanceTimersByTime(900); + mon.track("a"); // already tracked: must be a no-op, not a reset + vi.advanceTimersByTime(100); + expect(idled).toEqual(["a"]); + }); + + it("re-arms after onIdle fires and the pod is tracked again", () => { + const idled: string[] = []; + const mon = new IdleMonitor({ idleMs: 1000, onIdle: (id) => idled.push(id) }); + mon.track("a"); + mon.recordActivity("a", 0); + vi.advanceTimersByTime(1000); + expect(idled).toEqual(["a"]); + + mon.track("a"); + mon.recordActivity("a", 0); + vi.advanceTimersByTime(1000); + expect(idled).toEqual(["a", "a"]); + }); +}); diff --git a/packages/stack/src/fleet/PodManifest.ts b/packages/stack/src/fleet/PodManifest.ts new file mode 100644 index 0000000000..dcaab939f8 --- /dev/null +++ b/packages/stack/src/fleet/PodManifest.ts @@ -0,0 +1,70 @@ +import { createHash } from "node:crypto"; +import { + fillServiceVersionManifest, + type ServiceName, + type VersionManifest, +} from "@supabase/stack"; +import type { FunctionsConfig } from "@supabase/stack"; +import type { ProvisionedServiceName, ProvisionedStackVersions } from "@supabase/stack/provisioned"; + +export interface PodManifest { + readonly id: string; + readonly versions: ProvisionedStackVersions; + readonly services: ReadonlyArray; + readonly flags: { readonly supautils: boolean }; + /** Whether the pod was provisioned from a warm (service-premigrated) template; reset re-clones the same kind. */ + readonly warm: boolean; + /** Stable external database endpoint owned by the fleet edge proxy. */ + readonly dbPort: number; + /** Stable external Supabase gateway endpoint owned by the fleet edge proxy. */ + readonly apiPort: number; + readonly postgresPassword: string; + readonly jwtSecret: string; + readonly publishableKey: string; + readonly secretKey: string; + readonly projectDir?: string; + readonly functions?: FunctionsConfig | false; + readonly createdAt: string; +} + +const keyHash = (value: string): string => + createHash("sha256").update(value).digest("hex").slice(0, 16); + +interface TemplateKeyOptions { + readonly postgresPassword?: string; +} + +const DEFAULT_POSTGRES_PASSWORD = "postgres"; + +export const baseTemplateKey = (postgresVersion: string, opts: TemplateKeyOptions = {}): string => { + const canonical = JSON.stringify({ + postgresVersion, + postgresPassword: opts.postgresPassword ?? DEFAULT_POSTGRES_PASSWORD, + }); + return `pg-${keyHash(canonical)}`; +}; + +export const templateKey = ( + versions: Partial, + enabledServices: ReadonlyArray = [], + opts: TemplateKeyOptions = {}, +): string => { + const canonical = JSON.stringify({ + versions: Object.fromEntries(Object.entries(versions).sort(([a], [b]) => a.localeCompare(b))), + enabledServices: [...new Set(enabledServices)].sort((a, b) => a.localeCompare(b)), + postgresPassword: opts.postgresPassword ?? DEFAULT_POSTGRES_PASSWORD, + }); + return `tuple-${keyHash(canonical)}`; +}; + +export const resolveTemplateVersions = ( + versions: Partial, + enabledServices: ReadonlyArray, +): ProvisionedStackVersions => { + const full = fillServiceVersionManifest(versions); + const resolved: Partial> = {}; + for (const service of new Set(enabledServices)) { + resolved[service] = full[service]; + } + return { ...resolved, postgres: full.postgres }; +}; diff --git a/packages/stack/src/fleet/PodManifest.unit.test.ts b/packages/stack/src/fleet/PodManifest.unit.test.ts new file mode 100644 index 0000000000..4fd76f7bf6 --- /dev/null +++ b/packages/stack/src/fleet/PodManifest.unit.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_VERSIONS } from "@supabase/stack"; +import { baseTemplateKey, resolveTemplateVersions, templateKey } from "./PodManifest.ts"; + +describe("templateKey", () => { + it("is stable across key order", () => { + expect(templateKey({ postgres: "17.6.1.143", auth: "2.192.0" })).toBe( + templateKey({ auth: "2.192.0", postgres: "17.6.1.143" }), + ); + }); + it("changes when any version changes", () => { + expect(templateKey({ postgres: "17.6.1.143", auth: "2.192.0" })).not.toBe( + templateKey({ postgres: "17.6.1.143", auth: "2.192.1" }), + ); + }); + it("includes enabled services in canonical order", () => { + const versions = { postgres: "17.6.1.143", auth: "2.192.0" }; + expect(templateKey(versions, ["auth", "postgrest"])).toBe( + templateKey(versions, ["postgrest", "auth"]), + ); + expect(templateKey(versions, ["auth"])).not.toBe(templateKey(versions, ["auth", "postgrest"])); + }); + it("base key is stable and path-safe", () => { + expect(baseTemplateKey("17.6.1.143")).toBe(baseTemplateKey("17.6.1.143")); + expect(baseTemplateKey("../17.6.1.143")).toMatch(/^pg-[a-f0-9]{16}$/); + expect(baseTemplateKey("../17.6.1.143")).not.toContain(".."); + }); + it("keys templates by the postgres password used to initialize the data dir", () => { + expect(baseTemplateKey("17.6.1.143", { postgresPassword: "one" })).not.toBe( + baseTemplateKey("17.6.1.143", { postgresPassword: "two" }), + ); + expect( + templateKey({ postgres: "17.6.1.143", auth: "2.192.0" }, ["auth"], { + postgresPassword: "one", + }), + ).not.toBe( + templateKey({ postgres: "17.6.1.143", auth: "2.192.0" }, ["auth"], { + postgresPassword: "two", + }), + ); + }); + + it("resolves default versions only for postgres and enabled services", () => { + expect(resolveTemplateVersions({ postgres: "17.6.1.143" }, ["postgrest"])).toEqual({ + postgres: "17.6.1.143", + postgrest: DEFAULT_VERSIONS.postgrest, + }); + }); +}); diff --git a/packages/stack/src/fleet/PodRegistry.ts b/packages/stack/src/fleet/PodRegistry.ts new file mode 100644 index 0000000000..4070701cf0 --- /dev/null +++ b/packages/stack/src/fleet/PodRegistry.ts @@ -0,0 +1,319 @@ +import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { STACK_SERVICE_NAMES } from "@supabase/stack"; +import type { ServiceName } from "@supabase/stack"; +import type { ProvisionedServiceName } from "@supabase/stack/provisioned"; +import type { PodManifest } from "./PodManifest.ts"; +import type { VersionManifest } from "@supabase/stack"; +import { FLEET_PUBLIC_PORT_RANGE } from "./PortRegistry.ts"; + +const POD_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +function serviceNameFrom(value: string): ServiceName | undefined { + switch (value) { + case "postgres": + case "postgrest": + case "auth": + case "edge-runtime": + case "realtime": + case "storage": + case "imgproxy": + case "mailpit": + case "pgmeta": + case "studio": + case "analytics": + case "vector": + case "pooler": + return value; + default: + return undefined; + } +} + +function isValidPodId(id: string): boolean { + return POD_ID_RE.test(id) && basename(id) === id; +} + +function validatePodId(id: string): string { + if (!isValidPodId(id)) { + throw new Error(`invalid pod id: ${id}`); + } + return id; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + return typeof error.code === "string" ? error.code : undefined; +} + +function isFleetPort(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isInteger(value) && + value >= FLEET_PUBLIC_PORT_RANGE.min && + value <= FLEET_PUBLIC_PORT_RANGE.max + ); +} + +function parseVersions(value: unknown): Partial | undefined { + if (!isRecord(value)) return undefined; + let postgres: string | undefined; + let postgrest: string | undefined; + let auth: string | undefined; + let edgeRuntime: string | undefined; + let realtime: string | undefined; + let storage: string | undefined; + let imgproxy: string | undefined; + let mailpit: string | undefined; + let pgmeta: string | undefined; + let studio: string | undefined; + let analytics: string | undefined; + let vector: string | undefined; + let pooler: string | undefined; + for (const [name, version] of Object.entries(value)) { + const service = serviceNameFrom(name); + if (service === undefined || typeof version !== "string") return undefined; + switch (service) { + case "postgres": + postgres = version; + break; + case "postgrest": + postgrest = version; + break; + case "auth": + auth = version; + break; + case "edge-runtime": + edgeRuntime = version; + break; + case "realtime": + realtime = version; + break; + case "storage": + storage = version; + break; + case "imgproxy": + imgproxy = version; + break; + case "mailpit": + mailpit = version; + break; + case "pgmeta": + pgmeta = version; + break; + case "studio": + studio = version; + break; + case "analytics": + analytics = version; + break; + case "vector": + vector = version; + break; + case "pooler": + pooler = version; + break; + } + } + return { + ...(postgres === undefined ? {} : { postgres }), + ...(postgrest === undefined ? {} : { postgrest }), + ...(auth === undefined ? {} : { auth }), + ...(edgeRuntime === undefined ? {} : { "edge-runtime": edgeRuntime }), + ...(realtime === undefined ? {} : { realtime }), + ...(storage === undefined ? {} : { storage }), + ...(imgproxy === undefined ? {} : { imgproxy }), + ...(mailpit === undefined ? {} : { mailpit }), + ...(pgmeta === undefined ? {} : { pgmeta }), + ...(studio === undefined ? {} : { studio }), + ...(analytics === undefined ? {} : { analytics }), + ...(vector === undefined ? {} : { vector }), + ...(pooler === undefined ? {} : { pooler }), + }; +} + +function parseServices(value: unknown): ReadonlyArray | undefined { + if (!Array.isArray(value)) return undefined; + const services = new Set(); + for (const name of value) { + if (!isProvisionedServiceName(name) || services.has(name)) { + return undefined; + } + services.add(name); + } + return [...services]; +} + +function isProvisionedServiceName(value: unknown): value is ProvisionedServiceName { + return typeof value === "string" && STACK_SERVICE_NAMES.some((service) => service === value); +} + +function parseFunctions(value: unknown): PodManifest["functions"] | undefined { + if (value === undefined || value === false) return value; + if (!isRecord(value)) return undefined; + if (value.envFile !== undefined && typeof value.envFile !== "string") return undefined; + if (value.noVerifyJwt !== undefined && typeof value.noVerifyJwt !== "boolean") return undefined; + return { + ...(value.envFile === undefined ? {} : { envFile: value.envFile }), + ...(value.noVerifyJwt === undefined ? {} : { noVerifyJwt: value.noVerifyJwt }), + }; +} + +function parseManifest(value: unknown): PodManifest | undefined { + if (!isRecord(value)) return undefined; + if (typeof value.id !== "string" || !isValidPodId(value.id)) return undefined; + const versions = parseVersions(value.versions); + const services = parseServices(value.services); + if ( + versions === undefined || + services === undefined || + !isFleetPort(value.dbPort) || + !isFleetPort(value.apiPort) || + value.dbPort === value.apiPort + ) { + return undefined; + } + // Provisioning always records the exact versions a pod was initialized + // with (see resolveTemplateVersions); a manifest missing them would make + // the next wake silently boot whatever the CURRENT defaults are against + // an existing data dir, so treat it as corrupt. + if (versions.postgres === undefined) return undefined; + for (const service of services) { + if (versions[service] === undefined) return undefined; + } + if (!isRecord(value.flags) || typeof value.flags.supautils !== "boolean") return undefined; + if (typeof value.warm !== "boolean") return undefined; + if (typeof value.postgresPassword !== "string" || value.postgresPassword.length === 0) { + return undefined; + } + if ( + typeof value.jwtSecret !== "string" || + value.jwtSecret.length < 32 || + typeof value.publishableKey !== "string" || + value.publishableKey.length === 0 || + typeof value.secretKey !== "string" || + value.secretKey.length === 0 + ) { + return undefined; + } + if (value.projectDir !== undefined && typeof value.projectDir !== "string") return undefined; + const functions = parseFunctions(value.functions); + if (value.functions !== undefined && functions === undefined) return undefined; + if (typeof value.createdAt !== "string" || Number.isNaN(Date.parse(value.createdAt))) { + return undefined; + } + return { + id: value.id, + versions: { ...versions, postgres: versions.postgres }, + services, + flags: { supautils: value.flags.supautils }, + warm: value.warm, + dbPort: value.dbPort, + apiPort: value.apiPort, + postgresPassword: value.postgresPassword, + jwtSecret: value.jwtSecret, + publishableKey: value.publishableKey, + secretKey: value.secretKey, + ...(value.projectDir === undefined ? {} : { projectDir: value.projectDir }), + ...(value.functions === undefined ? {} : { functions }), + createdAt: value.createdAt, + }; +} + +/** + * Persists pod manifests on disk, one per pod directory: `podsRoot//pod.json`. + * The pod's data directory lives alongside it at `podsRoot//data`. + */ +export class PodRegistry { + constructor(private readonly podsRoot: string) {} + + podDir(id: string): string { + return join(this.podsRoot, validatePodId(id)); + } + + dataDir(id: string): string { + return join(this.podDir(id), "data"); + } + + /** + * Whether ANY pod directory exists for `id`, even one whose pod.json is + * missing or unreadable — provisioning must treat those as occupied rather + * than as free ids whose data it may clobber. + */ + async exists(id: string): Promise { + return stat(this.podDir(id)).then( + () => true, + (error: unknown) => { + if (errorCode(error) === "ENOENT") return false; + throw error; + }, + ); + } + + async read(id: string): Promise { + const raw = await readFile(join(this.podDir(id), "pod.json"), "utf8").catch( + (error: unknown) => { + if (errorCode(error) === "ENOENT") return undefined; + throw error; + }, + ); + if (raw === undefined) return undefined; + try { + const manifest = parseManifest(JSON.parse(raw)); + return manifest?.id === id ? manifest : undefined; + } catch { + return undefined; + } + } + + async write(manifest: PodManifest): Promise { + const parsed = parseManifest(manifest); + if (parsed === undefined || parsed.id !== manifest.id) { + throw new Error(`invalid pod manifest: ${manifest.id}`); + } + const dir = this.podDir(manifest.id); + // The manifest holds the pod's postgres password in plaintext, so keep the + // directory and file owner-only regardless of the process umask. + await mkdir(dir, { recursive: true, mode: 0o700 }); + const tmp = join(dir, `pod.json.tmp-${process.pid}-${Date.now()}`); + await writeFile(tmp, JSON.stringify(manifest, null, 2), { mode: 0o600 }); + await rename(tmp, join(dir, "pod.json")); + } + + /** + * All pod directory ids on disk, INCLUDING those whose pod.json is missing + * or malformed — startup reconciliation must reap stale postmasters even + * for pods it can no longer parse. + */ + async listIds(): Promise { + // Only a MISSING root means "no pods". Any other scan failure (EACCES, + // ENOTDIR, transient I/O) must propagate: reporting an empty fleet would + // let startup reconciliation free every port reservation while the pod + // directories still exist behind the unreadable root. + const entries = await readdir(this.podsRoot, { withFileTypes: true }).catch( + (error: unknown) => { + if (errorCode(error) === "ENOENT") return []; + throw error; + }, + ); + // Only directories: a stray regular file whose name matches the id regex + // would make startup cleanup paths fail with ENOTDIR. + return entries + .filter((entry) => entry.isDirectory() && isValidPodId(entry.name)) + .map((entry) => entry.name); + } + + async list(): Promise { + const ids = await this.listIds(); + const manifests = await Promise.all(ids.map((id) => this.read(id))); + return manifests.filter((m): m is PodManifest => m !== undefined); + } + + async remove(id: string): Promise { + await rm(this.podDir(id), { recursive: true, force: true }); + } +} diff --git a/packages/stack/src/fleet/PodRegistry.unit.test.ts b/packages/stack/src/fleet/PodRegistry.unit.test.ts new file mode 100644 index 0000000000..30f0906ae5 --- /dev/null +++ b/packages/stack/src/fleet/PodRegistry.unit.test.ts @@ -0,0 +1,182 @@ +import { chmod, mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { PodManifest } from "./PodManifest.ts"; +import { PodRegistry } from "./PodRegistry.ts"; + +describe("PodRegistry", () => { + it("rejects ids that could escape the pod root", async () => { + const pods = new PodRegistry(await mkdtemp(join(tmpdir(), "pods-"))); + + expect(() => pods.podDir("../templates")).toThrow(/invalid pod id/); + expect(() => pods.dataDir("nested/pod")).toThrow(/invalid pod id/); + expect(() => pods.podDir("pod-a")).not.toThrow(); + }); + + it("ignores non-pod entries while listing persisted pods", async () => { + const root = await mkdtemp(join(tmpdir(), "pods-")); + const pods = new PodRegistry(root); + const manifest = { + id: "pod-a", + versions: { postgres: "17.6.1.143" }, + services: [], + flags: { supautils: false }, + warm: false, + dbPort: 55000, + apiPort: 55001, + postgresPassword: "postgres", + jwtSecret: "01234567890123456789012345678901", + publishableKey: "sb_publishable_test", + secretKey: "sb_secret_test", + createdAt: "2026-07-08T00:00:00.000Z", + }; + + await pods.write(manifest); + await writeFile(join(root, ".DS_Store"), "not a pod"); + + await expect(pods.list()).resolves.toEqual([manifest]); + }); + + it("skips malformed manifests while listing persisted pods", async () => { + const root = await mkdtemp(join(tmpdir(), "pods-")); + const pods = new PodRegistry(root); + await mkdir(join(root, "bad")); + await writeFile(join(root, "bad", "pod.json"), "not-json"); + + await expect(pods.read("bad")).resolves.toBeUndefined(); + await expect(pods.list()).resolves.toEqual([]); + }); + + it("rejects manifests whose database port is outside the fleet range", async () => { + const root = await mkdtemp(join(tmpdir(), "pods-")); + const pods = new PodRegistry(root); + const base = { + versions: { postgres: "17.6.1.143" }, + services: [], + flags: { supautils: false }, + warm: false, + postgresPassword: "postgres", + apiPort: 55001, + jwtSecret: "01234567890123456789012345678901", + publishableKey: "sb_publishable_test", + secretKey: "sb_secret_test", + createdAt: "2026-07-08T00:00:00.000Z", + }; + + await mkdir(join(root, "bad-public")); + await writeFile( + join(root, "bad-public", "pod.json"), + JSON.stringify({ + ...base, + id: "bad-public", + dbPort: 45000, + }), + ); + + await expect(pods.read("bad-public")).resolves.toBeUndefined(); + await expect(pods.list()).resolves.toEqual([]); + }); + + it("ignores non-directory pod entries", async () => { + const root = await mkdtemp(join(tmpdir(), "pods-")); + const pods = new PodRegistry(root); + // A stray regular file matching the id regex must not surface as a pod id. + await writeFile(join(root, "not-a-pod"), "just a file"); + + await expect(pods.listIds()).resolves.toEqual([]); + await expect(pods.list()).resolves.toEqual([]); + }); + + it("propagates pod-root scan failures instead of reporting an empty fleet", async () => { + if (typeof process.getuid === "function" && process.getuid() === 0) return; // root ignores modes + const root = await mkdtemp(join(tmpdir(), "pods-")); + const pods = new PodRegistry(root); + try { + await chmod(root, 0o000); + // Treating EACCES as "no pods" would let startup free every port + // reservation while pod dirs still exist behind the unreadable root. + await expect(pods.listIds()).rejects.toThrow(); + } finally { + await chmod(root, 0o700); + } + // A genuinely missing root still means "no pods". + const missing = new PodRegistry(join(root, "does-not-exist")); + await expect(missing.listIds()).resolves.toEqual([]); + }); + + it("propagates lookup failures instead of treating an unreadable pod as absent", async () => { + if (typeof process.getuid === "function" && process.getuid() === 0) return; // root ignores modes + const root = await mkdtemp(join(tmpdir(), "pods-")); + const pods = new PodRegistry(root); + await mkdir(join(root, "pod-a")); + try { + await chmod(root, 0o000); + await expect(pods.exists("pod-a")).rejects.toThrow(); + await expect(pods.read("pod-a")).rejects.toThrow(); + } finally { + await chmod(root, 0o700); + } + }); + + it("refuses to persist manifests that cannot be read back", async () => { + const root = await mkdtemp(join(tmpdir(), "pods-")); + const pods = new PodRegistry(root); + const invalid = { + id: "pod-a", + versions: {}, + services: [], + flags: { supautils: false }, + warm: false, + dbPort: 55000, + apiPort: 55001, + postgresPassword: "postgres", + jwtSecret: "01234567890123456789012345678901", + publishableKey: "sb_publishable_test", + secretKey: "sb_secret_test", + createdAt: "2026-07-08T00:00:00.000Z", + }; + + await expect(pods.write(invalid as unknown as PodManifest)).rejects.toThrow( + "invalid pod manifest", + ); + await expect(pods.exists("pod-a")).resolves.toBe(false); + }); + + it("rejects manifests missing versions for postgres or enabled services", async () => { + const root = await mkdtemp(join(tmpdir(), "pods-")); + const pods = new PodRegistry(root); + const base = { + services: [], + flags: { supautils: false }, + warm: false, + dbPort: 55000, + apiPort: 55001, + postgresPassword: "postgres", + jwtSecret: "01234567890123456789012345678901", + publishableKey: "sb_publishable_test", + secretKey: "sb_secret_test", + createdAt: "2026-07-08T00:00:00.000Z", + }; + + await mkdir(join(root, "no-postgres")); + await writeFile( + join(root, "no-postgres", "pod.json"), + JSON.stringify({ ...base, id: "no-postgres", versions: {} }), + ); + await mkdir(join(root, "no-auth-version")); + await writeFile( + join(root, "no-auth-version", "pod.json"), + JSON.stringify({ + ...base, + id: "no-auth-version", + versions: { postgres: "17.6.1.143" }, + services: ["auth"], + }), + ); + + await expect(pods.read("no-postgres")).resolves.toBeUndefined(); + await expect(pods.read("no-auth-version")).resolves.toBeUndefined(); + await expect(pods.list()).resolves.toEqual([]); + }); +}); diff --git a/packages/stack/src/fleet/PortRegistry.ts b/packages/stack/src/fleet/PortRegistry.ts new file mode 100644 index 0000000000..633813456a --- /dev/null +++ b/packages/stack/src/fleet/PortRegistry.ts @@ -0,0 +1,210 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { dirname } from "node:path"; + +export interface PodEndpoint { + readonly dbPort: number; + readonly apiPort: number; +} + +interface PortState { + readonly pods: Record; +} + +const DEFAULT_BASE_PORT = 55_000; +const MAX_PORT = 65_535; + +/** Proxy-owned listeners bind here; persisted pod endpoints must stay in this range. */ +export const FLEET_PUBLIC_PORT_RANGE = { min: DEFAULT_BASE_PORT, max: MAX_PORT } as const; + +function freshState(): PortState { + return { pods: {} }; +} + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + return typeof error.code === "string" ? error.code : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isFleetPort(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isInteger(value) && + value >= FLEET_PUBLIC_PORT_RANGE.min && + value <= FLEET_PUBLIC_PORT_RANGE.max + ); +} + +function isPodEndpoint(value: unknown): value is PodEndpoint { + return ( + isRecord(value) && + isFleetPort(value.dbPort) && + isFleetPort(value.apiPort) && + value.dbPort !== value.apiPort + ); +} + +function isValidState(value: unknown): value is PortState { + if (!isRecord(value) || !isRecord(value.pods)) return false; + const used = new Set(); + for (const endpoint of Object.values(value.pods)) { + if (!isPodEndpoint(endpoint) || used.has(endpoint.dbPort) || used.has(endpoint.apiPort)) { + return false; + } + used.add(endpoint.dbPort); + used.add(endpoint.apiPort); + } + return true; +} + +function isPortAvailable(port: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE" || error.code === "EACCES") { + resolve(false); + } else { + reject(error); + } + }); + server.listen(port, "127.0.0.1", () => { + server.close((error) => (error ? reject(error) : resolve(true))); + }); + }); +} + +/** + * Persistent registry for the database and API ports held open by EdgeProxy + * for the pod's entire lifetime. Internal stack ports belong to a warm runtime + * and are allocated by @supabase/stack on each wake, so they are intentionally + * absent from this state. + * + * Mutations are serialized in-process; the fleet-root lock provides the + * cross-process single-owner guarantee. New allocations probe the host before + * persisting so an unrelated listener does not permanently poison the first + * candidate in the fleet range. + */ +export class PortRegistry { + private mutationQueue: Promise = Promise.resolve(); + + private constructor( + private readonly stateFile: string, + private state: PortState, + ) {} + + static async load(stateFile: string): Promise { + const raw = await readFile(stateFile, "utf8").catch((error: unknown) => { + if (errorCode(error) === "ENOENT") return undefined; + throw error; + }); + if (raw === undefined) return new PortRegistry(stateFile, freshState()); + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + await PortRegistry.quarantine(stateFile); + return new PortRegistry(stateFile, freshState()); + } + + if (!isValidState(parsed)) { + await PortRegistry.quarantine(stateFile); + return new PortRegistry(stateFile, freshState()); + } + return new PortRegistry(stateFile, parsed); + } + + private static async quarantine(stateFile: string): Promise { + await rename(stateFile, `${stateFile}.corrupt`); + } + + get(podId: string): PodEndpoint | undefined { + const endpoint = Object.hasOwn(this.state.pods, podId) ? this.state.pods[podId] : undefined; + return endpoint === undefined ? undefined : { ...endpoint }; + } + + async allocate(podId: string): Promise { + return this.withMutation(async () => { + const existing = this.get(podId); + if (existing !== undefined) return existing; + + const used = new Set( + Object.values(this.state.pods).flatMap((endpoint) => [endpoint.dbPort, endpoint.apiPort]), + ); + const allocatePort = async (): Promise => { + let port = DEFAULT_BASE_PORT; + while (port <= MAX_PORT && (used.has(port) || !(await isPortAvailable(port)))) { + port += 1; + } + if (port > MAX_PORT) throw new Error("PortRegistry: exhausted fleet port range"); + used.add(port); + return port; + }; + + const endpoint = { dbPort: await allocatePort(), apiPort: await allocatePort() }; + this.state = { pods: { ...this.state.pods, [podId]: endpoint } }; + await this.persist(); + return { ...endpoint }; + }); + } + + async release(podId: string): Promise { + await this.withMutation(async () => { + const pods = { ...this.state.pods }; + delete pods[podId]; + this.state = { pods }; + await this.persist(); + }); + } + + async reconcile(endpoints: ReadonlyMap): Promise { + await this.withMutation(async () => { + const pods: Record = {}; + const used = new Map(); + for (const [podId, endpoint] of endpoints) { + if (!isPodEndpoint(endpoint)) { + throw new Error( + `PortRegistry: cannot reconcile pod "${podId}" with invalid endpoint ${JSON.stringify(endpoint)}`, + ); + } + for (const port of [endpoint.dbPort, endpoint.apiPort]) { + const owner = used.get(port); + if (owner !== undefined) { + throw new Error( + `PortRegistry: cannot reconcile pod "${podId}" on port ${port}; port already assigned to pod "${owner}"`, + ); + } + used.set(port, podId); + } + pods[podId] = { ...endpoint }; + } + this.state = { pods }; + await this.persist(); + }); + } + + private async withMutation(body: () => Promise): Promise { + const previous = this.mutationQueue; + let release: () => void = () => {}; + this.mutationQueue = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await body(); + } finally { + release(); + } + } + + private async persist(): Promise { + await mkdir(dirname(this.stateFile), { recursive: true }); + const tmp = `${this.stateFile}.tmp`; + await writeFile(tmp, JSON.stringify(this.state, null, 2)); + await rename(tmp, this.stateFile); + } +} diff --git a/packages/stack/src/fleet/PortRegistry.unit.test.ts b/packages/stack/src/fleet/PortRegistry.unit.test.ts new file mode 100644 index 0000000000..23d0a1a647 --- /dev/null +++ b/packages/stack/src/fleet/PortRegistry.unit.test.ts @@ -0,0 +1,145 @@ +import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { createServer, type Server } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PortRegistry } from "./PortRegistry.ts"; + +describe("PortRegistry", () => { + it("allocates one stable endpoint per pod and persists it", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const registry = await PortRegistry.load(file); + + const a = await registry.allocate("pod-a"); + const b = await registry.allocate("pod-b"); + + expect(a.dbPort).not.toBe(b.dbPort); + expect(a.apiPort).not.toBe(b.apiPort); + expect(new Set([a.dbPort, a.apiPort, b.dbPort, b.apiPort]).size).toBe(4); + expect(a.dbPort).toBeGreaterThanOrEqual(55_000); + const reloaded = await PortRegistry.load(file); + expect(reloaded.get("pod-a")).toEqual(a); + expect(reloaded.get("pod-b")).toEqual(b); + }); + + it("is idempotent per pod and reuses released endpoints", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const registry = await PortRegistry.load(file); + const first = await registry.allocate("pod-a"); + + await expect(registry.allocate("pod-a")).resolves.toEqual(first); + await registry.release("pod-a"); + await expect(registry.allocate("pod-b")).resolves.toEqual(first); + }); + + it("treats inherited property names as ordinary pod ids", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const registry = await PortRegistry.load(file); + + const endpoint = await registry.allocate("constructor"); + + expect(registry.get("constructor")).toEqual(endpoint); + }); + + it("serializes concurrent mutations before persisting", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const registry = await PortRegistry.load(file); + const ids = Array.from({ length: 20 }, (_, index) => `pod-${index}`); + + const endpoints = await Promise.all(ids.map((id) => registry.allocate(id))); + + const allocatedPorts = endpoints.flatMap((endpoint) => [endpoint.dbPort, endpoint.apiPort]); + expect(new Set(allocatedPorts).size).toBe(ids.length * 2); + const reloaded = await PortRegistry.load(file); + expect(ids.every((id) => reloaded.get(id) !== undefined)).toBe(true); + }); + + it("skips a fleet port already owned by another host process", async () => { + const server = createServer(); + const occupied = await new Promise((resolve, reject) => { + server.once("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE" || error.code === "EACCES") resolve(undefined); + else reject(error); + }); + server.listen(55_000, "127.0.0.1", () => resolve(server)); + }); + try { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const registry = await PortRegistry.load(file); + const endpoint = await registry.allocate("pod-a"); + expect(endpoint.dbPort).not.toBe(55_000); + } finally { + await new Promise((resolve) => { + if (occupied === undefined) resolve(); + else occupied.close(() => resolve()); + }); + } + }); + + it("quarantines corrupt or structurally invalid state", async () => { + const root = await mkdtemp(join(tmpdir(), "ports-")); + const invalidJson = join(root, "invalid-json.json"); + await writeFile(invalidJson, "{not json"); + const fromInvalidJson = await PortRegistry.load(invalidJson); + expect(fromInvalidJson.get("pod-a")).toBeUndefined(); + await expect(readFile(`${invalidJson}.corrupt`, "utf8")).resolves.toBe("{not json"); + + const duplicate = join(root, "duplicate.json"); + await writeFile( + duplicate, + JSON.stringify({ + pods: { + "pod-a": { dbPort: 55_010, apiPort: 55_011 }, + "pod-b": { dbPort: 55_010, apiPort: 55_012 }, + }, + }), + ); + const fromDuplicate = await PortRegistry.load(duplicate); + expect(fromDuplicate.get("pod-a")).toBeUndefined(); + + const wrongRange = join(root, "wrong-range.json"); + await writeFile( + wrongRange, + JSON.stringify({ pods: { "pod-a": { dbPort: 45_000, apiPort: 55_001 } } }), + ); + const fromWrongRange = await PortRegistry.load(wrongRange); + expect(fromWrongRange.get("pod-a")).toBeUndefined(); + }); + + it("validates manifest endpoints during reconciliation", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const registry = await PortRegistry.load(file); + + await registry.reconcile( + new Map([ + ["pod-a", { dbPort: 55_010, apiPort: 55_011 }], + ["pod-b", { dbPort: 55_012, apiPort: 55_013 }], + ]), + ); + expect(registry.get("pod-a")).toEqual({ dbPort: 55_010, apiPort: 55_011 }); + await expect( + registry.reconcile( + new Map([ + ["pod-a", { dbPort: 55_010, apiPort: 55_011 }], + ["pod-b", { dbPort: 55_012, apiPort: 55_010 }], + ]), + ), + ).rejects.toThrow("already assigned"); + await expect( + registry.reconcile(new Map([["pod-a", { dbPort: 45_000, apiPort: 55_001 }]])), + ).rejects.toThrow("invalid endpoint"); + }); + + it("propagates state-file read errors instead of treating them as missing", async () => { + if (typeof process.getuid === "function" && process.getuid() === 0) return; + const root = await mkdtemp(join(tmpdir(), "ports-")); + const file = join(root, "state.json"); + await writeFile(file, JSON.stringify({ pods: {} })); + try { + await chmod(root, 0o000); + await expect(PortRegistry.load(file)).rejects.toThrow(); + } finally { + await chmod(root, 0o700); + } + }); +}); diff --git a/packages/stack/src/fleet/Provisioner.integration.test.ts b/packages/stack/src/fleet/Provisioner.integration.test.ts new file mode 100644 index 0000000000..4caa1530cc --- /dev/null +++ b/packages/stack/src/fleet/Provisioner.integration.test.ts @@ -0,0 +1,55 @@ +import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PodRegistry } from "./PodRegistry.ts"; +import { PortRegistry } from "./PortRegistry.ts"; +import { Provisioner } from "./Provisioner.ts"; +import { TemplateStore } from "./TemplateStore.ts"; + +const PG_VERSION = "17.6.1.143"; + +describe.skipIf(!process.env.FLEET_PG_TESTS)("Provisioner", () => { + async function makeProvisioner() { + const root = await mkdtemp(join(tmpdir(), "fleet-")); + const templates = new TemplateStore(join(root, "templates")); + const pods = new PodRegistry(join(root, "pods")); + const ports = await PortRegistry.load(join(root, "fleet-state.json")); + return { p: new Provisioner({ templates, pods, ports, postgresPassword: "postgres" }), pods }; + } + + it("creates, forks, resets, destroys", async () => { + const { p, pods } = await makeProvisioner(); + const a = await p.create({ id: "a", versions: { postgres: PG_VERSION } }); + expect(a.dbPort).toBeGreaterThan(0); + expect(await stat(join(pods.dataDir("a"), "PG_VERSION")).then(() => true)).toBe(true); + + // fork: divergence + await writeFile(join(pods.dataDir("a"), "marker.txt"), "from-a"); + const b = await p.fork("a", "b"); + expect(b.dbPort).not.toBe(a.dbPort); + await writeFile(join(pods.dataDir("b"), "marker.txt"), "from-b"); + expect(await readFile(join(pods.dataDir("a"), "marker.txt"), "utf8")).toBe("from-a"); + + // reset: marker disappears (re-cloned from template) + await p.reset("a"); + expect( + await stat(join(pods.dataDir("a"), "marker.txt")).then( + () => true, + () => false, + ), + ).toBe(false); + + await p.destroy("a"); + await p.destroy("b"); + expect(await pods.list()).toEqual([]); + }, 300_000); + + it("rejects duplicate ids", async () => { + const { p } = await makeProvisioner(); + await p.create({ id: "dup", versions: { postgres: PG_VERSION } }); + await expect(p.create({ id: "dup", versions: { postgres: PG_VERSION } })).rejects.toThrow( + /exists/, + ); + }, 300_000); +}); diff --git a/packages/stack/src/fleet/Provisioner.ts b/packages/stack/src/fleet/Provisioner.ts new file mode 100644 index 0000000000..b307ee5b84 --- /dev/null +++ b/packages/stack/src/fleet/Provisioner.ts @@ -0,0 +1,224 @@ +import { randomBytes } from "node:crypto"; +import { rename, rm } from "node:fs/promises"; +import { + SERVICE_NAMES, + validateEnabledServiceDependencies, + type FunctionsConfig, + type VersionManifest, +} from "@supabase/stack"; +import type { ProvisionedServiceName, ProvisionedStackVersions } from "@supabase/stack/provisioned"; +import { cloneDir } from "./cowClone.ts"; +import { resolveTemplateVersions, type PodManifest } from "./PodManifest.ts"; +import type { PodRegistry } from "./PodRegistry.ts"; +import type { PortRegistry } from "./PortRegistry.ts"; +import type { TemplateStore } from "./TemplateStore.ts"; + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + return typeof error.code === "string" ? error.code : undefined; +} + +function throwWithCleanup( + primary: unknown, + results: ReadonlyArray>, + message: string, +): never { + const cleanupErrors = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (cleanupErrors.length === 0) throw primary; + throw new AggregateError([primary, ...cleanupErrors], message); +} + +const SERVICE_NAME_SET = new Set(SERVICE_NAMES); + +/** + * JS callers can pass junk despite the types; anything unknown or non-boolean + * would be persisted into pod.json only for PodRegistry's strict parser to + * reject the whole manifest on the next read, turning the pod into an + * unreadable "unknown pod". Reject it up front instead. + */ +function validateServices(services: ReadonlyArray): void { + const seen = new Set(); + for (const name of services) { + if (!SERVICE_NAME_SET.has(name) || seen.has(name)) { + throw new Error(`invalid service entry: ${String(name)}`); + } + seen.add(name); + } +} + +/** Same rationale as validateServices: junk versions would persist into a pod.json the registry's strict parser then refuses to read back. */ +function validateVersions(versions: Partial): void { + for (const [name, version] of Object.entries(versions)) { + if (!SERVICE_NAME_SET.has(name) || typeof version !== "string" || version.length === 0) { + throw new Error(`invalid version entry: ${name}=${String(version)}`); + } + } +} + +export interface CreatePodOptions { + readonly id: string; + readonly versions: ProvisionedStackVersions; + readonly services?: ReadonlyArray; + readonly flags?: { readonly supautils?: boolean }; + /** Build/use a warm template (services pre-migrated). Default: base template. */ + readonly warm?: boolean; + readonly projectDir?: string; + readonly functions?: FunctionsConfig | false; +} + +const randomSecret = (prefix = "") => `${prefix}${randomBytes(32).toString("base64url")}`; + +/** + * Creates, resets, forks, and destroys pods by CoW-cloning template data + * directories (via `TemplateStore`) into per-pod data directories tracked by + * `PodRegistry`, and allocating/releasing ports via `PortRegistry`. + */ +export class Provisioner { + constructor( + private readonly deps: { + readonly templates: TemplateStore; + readonly pods: PodRegistry; + readonly ports: PortRegistry; + readonly postgresPassword: string; + }, + ) {} + + async create(opts: CreatePodOptions): Promise { + const { templates, pods, ports, postgresPassword } = this.deps; + // `exists` (not just `read`) so a pod directory with a corrupt/older + // manifest also counts as occupied — otherwise the failed clone below + // would hit the existing data dir and the catch-block cleanup would + // delete pod data this create never made. + if (await pods.exists(opts.id)) { + throw new Error(`pod already exists: ${opts.id}`); + } + const pgVersion = opts.versions.postgres; + if (pgVersion === undefined) throw new Error("versions.postgres is required"); + validateVersions(opts.versions); + const enabled = [...(opts.services ?? [])]; + validateServices(enabled); + const dependencyError = validateEnabledServiceDependencies(new Set(enabled)); + if (dependencyError !== undefined) { + throw new Error(dependencyError); + } + const resolvedVersions = resolveTemplateVersions(opts.versions, enabled); + const template = + opts.warm === true + ? await templates.ensureWarmTemplate(resolvedVersions, enabled) + : await templates.ensureBaseTemplate(pgVersion); + const allocated = await ports.allocate(opts.id); + try { + await cloneDir(template, pods.dataDir(opts.id)); + const manifest: PodManifest = { + id: opts.id, + versions: resolvedVersions, + services: enabled, + flags: { supautils: opts.flags?.supautils ?? false }, + warm: opts.warm === true, + dbPort: allocated.dbPort, + apiPort: allocated.apiPort, + postgresPassword, + jwtSecret: randomSecret(), + publishableKey: randomSecret("sb_publishable_"), + secretKey: randomSecret("sb_secret_"), + ...(opts.projectDir === undefined ? {} : { projectDir: opts.projectDir }), + ...(opts.functions === undefined ? {} : { functions: opts.functions }), + createdAt: new Date().toISOString(), + }; + await pods.write(manifest); + return manifest; + } catch (err) { + const cleanup = await Promise.allSettled([ + ports.release(opts.id), + rm(pods.podDir(opts.id), { recursive: true, force: true }), + ]); + throwWithCleanup(err, cleanup, `Failed to create and clean up pod ${opts.id}`); + } + } + + /** Re-clones the pod's data dir from the same kind of template it was created from. */ + async reset(id: string): Promise { + const { templates, pods, postgresPassword } = this.deps; + const manifest = await pods.read(id); + if (manifest === undefined) throw new Error(`unknown pod: ${id}`); + const pgVersion = manifest.versions.postgres; + if (pgVersion === undefined) throw new Error(`pod ${id} has no postgres version`); + const enabled = manifest.services; + const template = manifest.warm + ? await templates.ensureWarmTemplate(manifest.versions, enabled) + : await templates.ensureBaseTemplate(pgVersion); + const dataDir = pods.dataDir(id); + const tmpDataDir = `${dataDir}.reset-${process.pid}-${Date.now()}`; + const backupDataDir = `${dataDir}.backup-${process.pid}-${Date.now()}`; + let backedUp = false; + try { + await cloneDir(template, tmpDataDir); + await rename(dataDir, backupDataDir).then( + () => { + backedUp = true; + }, + (error: unknown) => { + if (errorCode(error) !== "ENOENT") throw error; + }, + ); + await rename(tmpDataDir, dataDir); + await pods.write({ ...manifest, postgresPassword }); + } catch (error) { + const cleanup: Array> = []; + if (backedUp) { + const removeNewData = await Promise.allSettled([ + rm(dataDir, { recursive: true, force: true }), + ]); + cleanup.push(...removeNewData); + if (removeNewData[0]?.status === "fulfilled") { + cleanup.push(...(await Promise.allSettled([rename(backupDataDir, dataDir)]))); + } + } + cleanup.push( + ...(await Promise.allSettled([rm(tmpDataDir, { recursive: true, force: true })])), + ); + throwWithCleanup(error, cleanup, `Failed to reset and restore pod ${id}`); + } + await rm(tmpDataDir, { recursive: true, force: true }); + // The reset is committed once the manifest is rewritten. Cleanup remains + // strict, but the old data is never restored after that commit point. + await rm(backupDataDir, { recursive: true, force: true }); + } + + /** Caller must ensure the source pod is stopped/suspended first. */ + async fork(sourceId: string, newId: string): Promise { + const { pods, ports } = this.deps; + const source = await pods.read(sourceId); + if (source === undefined) throw new Error(`unknown pod: ${sourceId}`); + if (await pods.exists(newId)) { + throw new Error(`pod already exists: ${newId}`); + } + const allocated = await ports.allocate(newId); + try { + await cloneDir(pods.dataDir(sourceId), pods.dataDir(newId)); + const manifest: PodManifest = { + ...source, + id: newId, + dbPort: allocated.dbPort, + apiPort: allocated.apiPort, + createdAt: new Date().toISOString(), + }; + await pods.write(manifest); + return manifest; + } catch (err) { + const cleanup = await Promise.allSettled([ + ports.release(newId), + rm(pods.podDir(newId), { recursive: true, force: true }), + ]); + throwWithCleanup(err, cleanup, `Failed to fork and clean up pod ${newId}`); + } + } + + async destroy(id: string): Promise { + const { pods, ports } = this.deps; + await pods.remove(id); + await ports.release(id); + } +} diff --git a/packages/stack/src/fleet/Provisioner.unit.test.ts b/packages/stack/src/fleet/Provisioner.unit.test.ts new file mode 100644 index 0000000000..1580b9a5a5 --- /dev/null +++ b/packages/stack/src/fleet/Provisioner.unit.test.ts @@ -0,0 +1,322 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DEFAULT_VERSIONS } from "@supabase/stack"; +import type { ProvisionedServiceName, ProvisionedStackVersions } from "@supabase/stack/provisioned"; +import { describe, expect, it } from "vitest"; +import { PodRegistry } from "./PodRegistry.ts"; +import { PortRegistry } from "./PortRegistry.ts"; +import { Provisioner, type CreatePodOptions } from "./Provisioner.ts"; +import type { TemplateStore } from "./TemplateStore.ts"; + +const PG_VERSION = "17.6.1.143"; + +/** + * A minimal stand-in for `TemplateStore` that skips booting a real Postgres + * stack: `ensureBaseTemplate`/`ensureWarmTemplate` just hand back a + * pre-populated (or, for the failure tests, deliberately missing) directory. + * Cast through `unknown` because `TemplateStore` has private members that a + * structural object literal can never satisfy. + */ +function fakeTemplateStore(templateDir: string, calls: string[]): TemplateStore { + return { + async ensureBaseTemplate(_postgresVersion: string): Promise { + calls.push("base"); + return templateDir; + }, + async ensureWarmTemplate( + _versions: ProvisionedStackVersions, + enabledServices: ReadonlyArray, + ): Promise { + calls.push(`warm:${[...enabledServices].sort().join(",")}`); + return templateDir; + }, + } as unknown as TemplateStore; +} + +async function makeHarness(templateDir: string) { + const root = await mkdtemp(join(tmpdir(), "fleet-unit-")); + const podsRoot = join(root, "pods"); + const pods = new PodRegistry(podsRoot); + const ports = await PortRegistry.load(join(root, "fleet-state.json")); + const templateCalls: string[] = []; + const templates = fakeTemplateStore(templateDir, templateCalls); + return { + p: new Provisioner({ templates, pods, ports, postgresPassword: "secret-password" }), + pods, + ports, + podsRoot, + templateCalls, + }; +} + +async function podsRootEntries(podsRoot: string): Promise { + return readdir(podsRoot).catch(() => [] as string[]); +} + +describe("Provisioner (unit, fake deps)", () => { + describe("create", () => { + it("provisions a pod from a valid template", async () => { + const templateDir = await mkdtemp(join(tmpdir(), "fleet-template-")); + await writeFile(join(templateDir, "PG_VERSION"), PG_VERSION); + const { p, pods, ports } = await makeHarness(templateDir); + + const manifest = await p.create({ id: "x", versions: { postgres: PG_VERSION } }); + + expect(manifest.id).toBe("x"); + expect(manifest.postgresPassword).toBe("secret-password"); + expect(ports.get("x")).toEqual({ dbPort: manifest.dbPort, apiPort: manifest.apiPort }); + expect(await pods.read("x")).toEqual(manifest); + }); + + it("releases the allocated ports and cleans up when cloning fails", async () => { + // Non-existent template dir => cloneDir's src stat/copy fails, throwing + // before the manifest is ever written. + const missingTemplateDir = join(await mkdtemp(join(tmpdir(), "fleet-template-")), "missing"); + const { p, ports, podsRoot } = await makeHarness(missingTemplateDir); + + await expect(p.create({ id: "x", versions: { postgres: PG_VERSION } })).rejects.toThrow(); + + expect(ports.get("x")).toBeUndefined(); + expect(await podsRootEntries(podsRoot)).not.toContain("x"); + }); + + it("refuses to create over a corrupt pod directory without deleting it", async () => { + const templateDir = await mkdtemp(join(tmpdir(), "fleet-template-")); + await writeFile(join(templateDir, "PG_VERSION"), PG_VERSION); + const { p, ports, podsRoot } = await makeHarness(templateDir); + + // A leftover pod dir whose manifest no longer parses: read() sees + // nothing, but the data underneath must survive a create attempt. + await mkdir(join(podsRoot, "zombie", "data"), { recursive: true }); + await writeFile(join(podsRoot, "zombie", "pod.json"), "not-json"); + await writeFile(join(podsRoot, "zombie", "data", "keep.txt"), "keep"); + + await expect(p.create({ id: "zombie", versions: { postgres: PG_VERSION } })).rejects.toThrow( + /exists/, + ); + + expect(ports.get("zombie")).toBeUndefined(); + await expect(readFile(join(podsRoot, "zombie", "data", "keep.txt"), "utf8")).resolves.toBe( + "keep", + ); + }); + + it("does not release ports belonging to a pre-existing pod on the duplicate-id path", async () => { + const templateDir = await mkdtemp(join(tmpdir(), "fleet-template-")); + await writeFile(join(templateDir, "PG_VERSION"), PG_VERSION); + const { p, ports } = await makeHarness(templateDir); + + const first = await p.create({ id: "dup", versions: { postgres: PG_VERSION } }); + await expect(p.create({ id: "dup", versions: { postgres: PG_VERSION } })).rejects.toThrow( + /exists/, + ); + + // The duplicate-id pre-check throws before any (re-)allocation, so the + // original pod's ports must still be intact. + expect(ports.get("dup")).toEqual({ dbPort: first.dbPort, apiPort: first.apiPort }); + }); + + it("records resolved default versions for enabled warm services", async () => { + const templateDir = await mkdtemp(join(tmpdir(), "fleet-template-")); + await writeFile(join(templateDir, "PG_VERSION"), PG_VERSION); + const { p } = await makeHarness(templateDir); + + const manifest = await p.create({ + id: "warm", + versions: { postgres: PG_VERSION }, + services: ["postgrest"], + warm: true, + }); + + expect(manifest.versions).toEqual({ + postgres: PG_VERSION, + postgrest: DEFAULT_VERSIONS.postgrest, + }); + }); + + it("rejects invalid service dependency combinations before provisioning", async () => { + const templateDir = await mkdtemp(join(tmpdir(), "fleet-template-")); + await writeFile(join(templateDir, "PG_VERSION"), PG_VERSION); + const { p, ports, podsRoot } = await makeHarness(templateDir); + + await expect( + p.create({ + id: "bad", + versions: { postgres: PG_VERSION }, + services: ["imgproxy"], + }), + ).rejects.toThrow(/imgproxy requires storage/); + + expect(ports.get("bad")).toBeUndefined(); + expect(await podsRootEntries(podsRoot)).not.toContain("bad"); + }); + + it("rejects non-string version entries before provisioning", async () => { + const templateDir = await mkdtemp(join(tmpdir(), "fleet-template-")); + await writeFile(join(templateDir, "PG_VERSION"), PG_VERSION); + const { p, ports, podsRoot } = await makeHarness(templateDir); + + // Simulates an untyped JS caller: a numeric version would persist into a + // pod.json the registry's strict parser refuses to read back. + const junkVersions = { + postgres: PG_VERSION, + auth: 123, + } as unknown as CreatePodOptions["versions"]; + await expect( + p.create({ id: "bad", versions: junkVersions, services: ["auth"] }), + ).rejects.toThrow(/invalid version entry/); + + expect(ports.get("bad")).toBeUndefined(); + expect(await podsRootEntries(podsRoot)).not.toContain("bad"); + }); + + it("rejects unknown service entries before provisioning", async () => { + const templateDir = await mkdtemp(join(tmpdir(), "fleet-template-")); + await writeFile(join(templateDir, "PG_VERSION"), PG_VERSION); + const { p, ports, podsRoot } = await makeHarness(templateDir); + + // Simulates an untyped JS caller: persisting "postrest" would make the + // registry's strict parser reject the whole manifest on the next read. + const junkServices = ["postrest"] as unknown as CreatePodOptions["services"]; + await expect( + p.create({ id: "bad", versions: { postgres: PG_VERSION }, services: junkServices }), + ).rejects.toThrow(/invalid service entry/); + + expect(ports.get("bad")).toBeUndefined(); + expect(await podsRootEntries(podsRoot)).not.toContain("bad"); + }); + + it("accepts Docker-backed services in the mixed provisioned runtime", async () => { + const templateDir = await mkdtemp(join(tmpdir(), "fleet-template-")); + await writeFile(join(templateDir, "PG_VERSION"), PG_VERSION); + const { p } = await makeHarness(templateDir); + + const manifest = await p.create({ + id: "storage", + versions: { postgres: PG_VERSION }, + services: ["storage"], + }); + + expect(manifest.services).toEqual(["storage"]); + expect(manifest.versions.storage).toBe(DEFAULT_VERSIONS.storage); + }); + }); + + describe("reset", () => { + it("re-clones the warm template for warm-created pods", async () => { + const templateDir = await mkdtemp(join(tmpdir(), "fleet-template-")); + await writeFile(join(templateDir, "PG_VERSION"), PG_VERSION); + const { p, templateCalls } = await makeHarness(templateDir); + + await p.create({ + id: "warm", + versions: { postgres: PG_VERSION }, + services: ["postgrest", "auth"], + warm: true, + }); + await p.reset("warm"); + + expect(templateCalls.at(-1)).toBe("warm:auth,postgrest"); + }); + + it("re-clones the base template for cold-created pods with enabled services", async () => { + const templateDir = await mkdtemp(join(tmpdir(), "fleet-template-")); + await writeFile(join(templateDir, "PG_VERSION"), PG_VERSION); + const { p, templateCalls } = await makeHarness(templateDir); + + await p.create({ + id: "cold", + versions: { postgres: PG_VERSION }, + services: ["postgrest"], + }); + await p.reset("cold"); + + expect(templateCalls.at(-1)).toBe("base"); + }); + + it("keeps the live data dir when cloning the replacement template fails", async () => { + const templateDir = await mkdtemp(join(tmpdir(), "fleet-template-")); + await writeFile(join(templateDir, "PG_VERSION"), PG_VERSION); + const { p, pods } = await makeHarness(templateDir); + + await p.create({ id: "x", versions: { postgres: PG_VERSION } }); + await writeFile(join(pods.dataDir("x"), "marker.txt"), "still-here"); + await rm(templateDir, { recursive: true, force: true }); + + await expect(p.reset("x")).rejects.toThrow(); + + await expect(readFile(join(pods.dataDir("x"), "marker.txt"), "utf8")).resolves.toBe( + "still-here", + ); + }); + }); + + describe("fork", () => { + it("clones an existing pod into a new one", async () => { + const templateDir = await mkdtemp(join(tmpdir(), "fleet-template-")); + await writeFile(join(templateDir, "PG_VERSION"), PG_VERSION); + const { p, pods, ports } = await makeHarness(templateDir); + + const source = await p.create({ id: "src", versions: { postgres: PG_VERSION } }); + const forked = await p.fork("src", "dst"); + + expect(forked.id).toBe("dst"); + expect(forked.dbPort).not.toBe(source.dbPort); + expect(ports.get("dst")).toEqual({ dbPort: forked.dbPort, apiPort: forked.apiPort }); + expect(await pods.read("dst")).toEqual(forked); + }); + + it("releases the allocated ports and cleans up when the clone fails", async () => { + const templateDir = await mkdtemp(join(tmpdir(), "fleet-template-")); + await writeFile(join(templateDir, "PG_VERSION"), PG_VERSION); + const { p, pods, ports, podsRoot } = await makeHarness(templateDir); + + await p.create({ id: "src", versions: { postgres: PG_VERSION } }); + + // Force the fork's clone step to fail deterministically: delete the + // source data dir out from under it. + await rm(pods.dataDir("src"), { recursive: true, force: true }); + + await expect(p.fork("src", "dst")).rejects.toThrow(); + + expect(ports.get("dst")).toBeUndefined(); + expect(await podsRootEntries(podsRoot)).toContain("src"); + expect(await podsRootEntries(podsRoot)).not.toContain("dst"); + }); + + it("refuses to fork onto an existing pod directory without deleting it", async () => { + const templateDir = await mkdtemp(join(tmpdir(), "fleet-template-")); + await writeFile(join(templateDir, "PG_VERSION"), PG_VERSION); + const { p, ports, podsRoot } = await makeHarness(templateDir); + + await p.create({ id: "src", versions: { postgres: PG_VERSION } }); + // An occupied directory whose manifest is unreadable still counts. + await mkdir(join(podsRoot, "dst", "data"), { recursive: true }); + await writeFile(join(podsRoot, "dst", "data", "keep.txt"), "keep"); + + await expect(p.fork("src", "dst")).rejects.toThrow(/exists/); + + expect(ports.get("dst")).toBeUndefined(); + await expect(readFile(join(podsRoot, "dst", "data", "keep.txt"), "utf8")).resolves.toBe( + "keep", + ); + }); + + it("does not release the source pod's ports when the new id already exists", async () => { + const templateDir = await mkdtemp(join(tmpdir(), "fleet-template-")); + await writeFile(join(templateDir, "PG_VERSION"), PG_VERSION); + const { p, ports } = await makeHarness(templateDir); + + const source = await p.create({ id: "src", versions: { postgres: PG_VERSION } }); + const existing = await p.create({ id: "dst", versions: { postgres: PG_VERSION } }); + + await expect(p.fork("src", "dst")).rejects.toThrow(/exists/); + + // The pre-check for an already-existing target throws before + // (re-)allocating, so neither pod's ports should be disturbed. + expect(ports.get("src")).toEqual({ dbPort: source.dbPort, apiPort: source.apiPort }); + expect(ports.get("dst")).toEqual({ dbPort: existing.dbPort, apiPort: existing.apiPort }); + }); + }); +}); diff --git a/packages/stack/src/fleet/TemplateStore.integration.test.ts b/packages/stack/src/fleet/TemplateStore.integration.test.ts new file mode 100644 index 0000000000..95f1ba2cd5 --- /dev/null +++ b/packages/stack/src/fleet/TemplateStore.integration.test.ts @@ -0,0 +1,38 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolvePostgresPassword } from "@supabase/stack"; +import { describe, expect, it } from "vitest"; +import { baseTemplateKey } from "./PodManifest.ts"; +import { TemplateStore } from "./TemplateStore.ts"; + +// Requires postgres binaries in the local cache; opt-in via env, since the first +// run downloads a real postgres release (~minutes) and boots it. +const POSTGRES_VERSION = "17.6.1.143"; + +describe.skipIf(!process.env.FLEET_PG_TESTS)("TemplateStore", () => { + it("builds a base template once and reuses it", async () => { + const root = await mkdtemp(join(tmpdir(), "templates-")); + const store = new TemplateStore(root); + const first = await store.ensureBaseTemplate(POSTGRES_VERSION); + expect(first).toContain( + baseTemplateKey(POSTGRES_VERSION, { postgresPassword: resolvePostgresPassword() }), + ); + // PGDATA got the micro profile + const conf = await readFile(join(first, "postgresql.conf"), "utf8"); + expect(conf).toContain("include_if_exists = 'micro.conf'"); + + const started = Date.now(); + const second = await store.ensureBaseTemplate(POSTGRES_VERSION); + expect(second).toBe(first); + expect(Date.now() - started).toBeLessThan(1000); // cache hit, no rebuild + }, 600_000); + + it("falls back to the base template when no services are enabled", async () => { + const root = await mkdtemp(join(tmpdir(), "templates-")); + const store = new TemplateStore(root); + const base = await store.ensureBaseTemplate(POSTGRES_VERSION); + const warm = await store.ensureWarmTemplate({ postgres: POSTGRES_VERSION }, []); + expect(warm).toBe(base); + }, 600_000); +}); diff --git a/packages/stack/src/fleet/TemplateStore.ts b/packages/stack/src/fleet/TemplateStore.ts new file mode 100644 index 0000000000..57c475da43 --- /dev/null +++ b/packages/stack/src/fleet/TemplateStore.ts @@ -0,0 +1,217 @@ +import { + mkdir, + mkdtemp, + open, + readFile, + rename, + rm, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { join } from "node:path"; +import { createStack, installMicroProfile, resolvePostgresPassword } from "@supabase/stack"; +import { + createProvisionedStack, + type ProvisionedServiceName, + type ProvisionedStackVersions, +} from "@supabase/stack/provisioned"; +import { cloneDir } from "./cowClone.ts"; +import { baseTemplateKey, resolveTemplateVersions, templateKey } from "./PodManifest.ts"; + +const LOCK_STALE_MS = 10 * 60 * 1000; +const LOCK_POLL_MS = 250; +const LOCK_HEARTBEAT_MS = Math.floor(LOCK_STALE_MS / 3); +const STACK_BOOT_LOCK_KEY = "__stack-boot"; + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + return typeof error.code === "string" ? error.code : undefined; +} + +/** + * Builds and caches golden Postgres template data directories that pods are later + * CoW-cloned from. + * + * - "base" templates are a one-shot `postgres`-only stack boot: `postgres-init` + * applies the baseline roles/schemas/migrations exactly as it does for a normal + * stack, then the micro profile (`micro.conf`/`pod.conf` includes) is installed + * before the data dir is frozen under `root/pg-/data`. + * - "warm" templates clone a base template and boot the requested services once + * so each self-migrates against a `provisioned` (post-init) data dir, then + * freeze the result under `root//data`. An empty service list has + * nothing to warm, so it resolves to the base template directly. + * + * Builds are concurrency-safe on one host via a per-key lockfile created with the + * `wx` flag; a stale lock (holder crashed) is reclaimed after `LOCK_STALE_MS`. + */ +export class TemplateStore { + constructor( + private readonly root: string, + private readonly postgresPassword = resolvePostgresPassword(), + ) {} + + private dataDir(key: string): string { + return join(this.root, key, "data"); + } + + async has(key: string): Promise { + return stat(join(this.root, key, "template.json")).then( + () => true, + () => false, + ); + } + + async ensureBaseTemplate(postgresVersion: string): Promise { + const postgresPassword = this.postgresPassword; + const key = baseTemplateKey(postgresVersion, { postgresPassword }); + if (await this.has(key)) return this.dataDir(key); + return this.withLock(key, async () => { + if (await this.has(key)) return this.dataDir(key); + const buildDir = await this.createBuildDir(key); + let frozen = false; + const buildDataDir = join(buildDir, "data"); + try { + await this.withStackBootLock(async () => { + // One-shot stack: postgres only, non-provisioned -> postgres-init applies + // roles/schemas/baseline migrations exactly as it does for a normal stack. + const stack = await createStack({ + mode: "native", + services: [], + postgres: { + version: postgresVersion, + dataDir: buildDataDir, + password: postgresPassword, + }, + functions: false, + }); + await stack.dispose(); + }); + await installMicroProfile(buildDataDir); + await this.freeze(buildDir, key, { + key, + postgresVersion, + builtAt: new Date().toISOString(), + }); + frozen = true; + return this.dataDir(key); + } finally { + if (!frozen) await rm(buildDir, { recursive: true, force: true }); + } + }); + } + + async ensureWarmTemplate( + versions: ProvisionedStackVersions, + enabledServices: ReadonlyArray, + ): Promise { + const pgVersion = versions.postgres; + if (pgVersion === undefined) throw new Error("versions.postgres is required"); + const postgresPassword = this.postgresPassword; + const base = await this.ensureBaseTemplate(pgVersion); + if (enabledServices.length === 0) return base; + + const resolvedVersions = resolveTemplateVersions(versions, enabledServices); + const key = templateKey(resolvedVersions, enabledServices, { postgresPassword }); + if (await this.has(key)) return this.dataDir(key); + return this.withLock(key, async () => { + if (await this.has(key)) return this.dataDir(key); + const buildDir = await this.createBuildDir(key); + let frozen = false; + const buildDataDir = join(buildDir, "data"); + + try { + await cloneDir(base, buildDataDir); + + await this.withStackBootLock(async () => { + // The clone is already post-init, so postgres-init is skipped (`provisioned: true`); + // each enabled service boots once and self-migrates against it. + const stack = await createProvisionedStack({ + dataDir: buildDataDir, + postgresPassword, + versions: resolvedVersions, + services: enabledServices, + startServices: enabledServices, + }); + await stack.dispose(); + }); + await this.freeze(buildDir, key, { + key, + versions: resolvedVersions, + services: enabledServices, + builtAt: new Date().toISOString(), + }); + frozen = true; + return this.dataDir(key); + } finally { + if (!frozen) await rm(buildDir, { recursive: true, force: true }); + } + }); + } + + private async createBuildDir(key: string): Promise { + await mkdir(this.root, { recursive: true }); + return mkdtemp(join(this.root, `${key}.build-`)); + } + + private async withStackBootLock(body: () => Promise): Promise { + return this.withLock(STACK_BOOT_LOCK_KEY, body); + } + + private async freeze(buildDir: string, key: string, marker: unknown): Promise { + const finalDir = join(this.root, key); + await writeFile(join(buildDir, "template.json"), JSON.stringify(marker)); + await rm(finalDir, { recursive: true, force: true }); + await rename(buildDir, finalDir); + } + + private async withLock(key: string, body: () => Promise): Promise { + const lockPath = join(this.root, `${key}.lock`); + const lockOwner = `${process.pid}:${Date.now()}:${Math.random()}`; + let lockHandle: FileHandle | undefined; + await mkdir(this.root, { recursive: true }); + for (;;) { + try { + const handle = await open(lockPath, "wx"); + try { + await handle.writeFile(lockOwner); + lockHandle = handle; + } finally { + if (lockHandle === undefined) await handle.close(); + } + break; + } catch (error) { + if (errorCode(error) !== "EEXIST") throw error; + const s = await stat(lockPath).catch(() => undefined); + if (s && Date.now() - s.mtimeMs > LOCK_STALE_MS) { + await unlink(lockPath).catch(() => {}); + continue; + } + await new Promise((r) => setTimeout(r, LOCK_POLL_MS)); + } + } + const heartbeat = setInterval(() => { + if (lockHandle !== undefined) void this.refreshLock(lockHandle); + }, LOCK_HEARTBEAT_MS); + try { + return await body(); + } finally { + clearInterval(heartbeat); + await this.releaseLock(lockPath, lockOwner); + await lockHandle?.close().catch(() => {}); + } + } + + private async refreshLock(lockHandle: FileHandle): Promise { + const now = new Date(); + await lockHandle.utimes(now, now).catch(() => {}); + } + + private async releaseLock(lockPath: string, lockOwner: string): Promise { + const currentOwner = await readFile(lockPath, "utf8").catch(() => undefined); + if (currentOwner === lockOwner) { + await unlink(lockPath).catch(() => {}); + } + } +} diff --git a/packages/stack/src/fleet/cowClone.integration.test.ts b/packages/stack/src/fleet/cowClone.integration.test.ts new file mode 100644 index 0000000000..a65f249c6e --- /dev/null +++ b/packages/stack/src/fleet/cowClone.integration.test.ts @@ -0,0 +1,99 @@ +import { + mkdtemp, + mkdir, + readFile, + readlink, + stat, + symlink, + writeFile, + chmod, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { cloneDir } from "./cowClone.ts"; + +/** Forces the CoW `cp` step to fail deterministically, so tests can exercise the fallback path. */ +const FORCE_FALLBACK = { cowCommand: "false" }; + +describe("cloneDir", () => { + it("clones a tree with content and modes; clones diverge", async () => { + const root = await mkdtemp(join(tmpdir(), "cow-test-")); + const src = join(root, "src"); + await mkdir(join(src, "sub"), { recursive: true }); + await writeFile(join(src, "sub", "a.txt"), "hello"); + await chmod(src, 0o700); + + const dest = join(root, "dest"); + await cloneDir(src, dest); + + expect(await readFile(join(dest, "sub", "a.txt"), "utf8")).toBe("hello"); + expect((await stat(dest)).mode & 0o777).toBe(0o700); + + await writeFile(join(dest, "sub", "a.txt"), "changed"); + expect(await readFile(join(src, "sub", "a.txt"), "utf8")).toBe("hello"); + }); + + it("refuses to clone onto an existing destination", async () => { + const root = await mkdtemp(join(tmpdir(), "cow-test-")); + const src = join(root, "src"); + await mkdir(src); + const dest = join(root, "dest"); + await mkdir(dest); + await expect(cloneDir(src, dest)).rejects.toThrow(/exists/); + }); + + it("creates the destination parent before attempting the CoW clone", async () => { + const root = await mkdtemp(join(tmpdir(), "cow-test-")); + const src = join(root, "src"); + await mkdir(src); + await writeFile(join(src, "file.txt"), "hello"); + + const dest = join(root, "missing-parent", "dest"); + await cloneDir(src, dest); + + expect(await readFile(join(dest, "file.txt"), "utf8")).toBe("hello"); + }); + + it("does not silently mix stale CoW leftovers with fallback output when the CoW command fails after partially writing dest", async () => { + const root = await mkdtemp(join(tmpdir(), "cow-test-")); + const src = join(root, "src"); + await mkdir(src, { recursive: true }); + await writeFile(join(src, "fresh.txt"), "fresh-from-src"); + + const dest = join(root, "dest"); + + // Use a "CoW command" that partially writes dest (mkdir + one file) and then fails, + // to model a real clonefile/reflink command that dies partway through. cloneDir + // invokes it as ` `, so `dest` is always the + // last argument regardless of platform-specific flags. + const partialWriteThenFail = join(root, "partial-write-then-fail.sh"); + await writeFile( + partialWriteThenFail, + `#!/bin/sh\nlast=""\nfor arg in "$@"; do last="$arg"; done\nmkdir -p "$last"\necho "leftover" > "$last/leftover.txt"\nexit 1\n`, + { mode: 0o755 }, + ); + + await cloneDir(src, dest, { cowCommand: partialWriteThenFail }); + + // The fallback must have run to completion (fresh.txt present)... + expect(await readFile(join(dest, "fresh.txt"), "utf8")).toBe("fresh-from-src"); + // ...and the stale leftover from the failed CoW attempt must be gone, not silently + // preserved alongside the fallback's output. + await expect(stat(join(dest, "leftover.txt"))).rejects.toThrow(); + }); + + it("preserves relative symlink targets through the fallback copy (verbatimSymlinks)", async () => { + const root = await mkdtemp(join(tmpdir(), "cow-test-")); + const src = join(root, "src"); + await mkdir(join(src, "sub"), { recursive: true }); + await writeFile(join(src, "sub", "target.txt"), "hi"); + await symlink("target.txt", join(src, "sub", "link.txt")); + + const dest = join(root, "dest"); + await cloneDir(src, dest, FORCE_FALLBACK); + + expect(await readlink(join(dest, "sub", "link.txt"))).toBe("target.txt"); + expect(await readFile(join(dest, "sub", "link.txt"), "utf8")).toBe("hi"); + }); +}); diff --git a/packages/stack/src/fleet/cowClone.ts b/packages/stack/src/fleet/cowClone.ts new file mode 100644 index 0000000000..a2a17579c0 --- /dev/null +++ b/packages/stack/src/fleet/cowClone.ts @@ -0,0 +1,78 @@ +import { spawn } from "node:child_process"; +import { cp, mkdir, rm, stat } from "node:fs/promises"; +import { dirname } from "node:path"; + +const run = (cmd: string, args: string[]): Promise => + new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: "ignore" }); + child.on("error", reject); + child.on("exit", (code) => resolve(code ?? 1)); + }); + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + return typeof error.code === "string" ? error.code : undefined; +} + +export interface CloneDirOptions { + /** + * Overrides the executable used for the platform-specific copy-on-write attempt + * (`cp` by default). Test-only seam: lets tests force the CoW step to fail + * deterministically (e.g. `"false"`, or a stub script) so the fallback path — + * including its pre-fallback cleanup of any partially-written `dest` — can be + * exercised without depending on filesystem-specific CoW failure conditions. + */ + cowCommand?: string; +} + +/** Copy-on-write directory clone: APFS clonefile on macOS, reflink on Linux, plain copy fallback. */ +export async function cloneDir( + src: string, + dest: string, + options?: CloneDirOptions, +): Promise { + const exists = await stat(dest).then( + () => true, + (error: unknown) => { + if (errorCode(error) === "ENOENT") return false; + throw error; + }, + ); + if (exists) throw new Error(`cloneDir: destination already exists: ${dest}`); + await mkdir(dirname(dest), { recursive: true }); + + const cowCommand = options?.cowCommand ?? "cp"; + let attemptedCow = false; + + if (process.platform === "darwin") { + attemptedCow = true; + if ((await run(cowCommand, ["-Rc", src, dest])) === 0) return; + } else if (process.platform === "linux") { + attemptedCow = true; + if ((await run(cowCommand, ["-R", "--reflink=auto", src, dest])) === 0) return; + } else if (options?.cowCommand) { + // No platform-specific CoW branch would normally run, but tests may still force + // the seam to exercise the fallback-cleanup path uniformly across platforms. + attemptedCow = true; + if ((await run(cowCommand, ["-R", src, dest])) === 0) return; + } + + // The CoW attempt failed after possibly writing a partial dest (e.g. a clonefile/reflink + // command that dies partway through a large tree). Remove any such leftovers before + // falling back, so dest never ends up a silent mix of truncated CoW output and fresh + // fallback files. + if (attemptedCow) { + await rm(dest, { recursive: true, force: true }); + } + + // Fallback (non-CoW filesystems, other platforms): plain recursive copy. + // `verbatimSymlinks: true` preserves relative symlink targets as-is; the default + // (false) rewrites them to absolute paths pointing back into `src`, making the + // clone silently depend on its source tree. + await cp(src, dest, { + recursive: true, + force: false, + errorOnExist: true, + verbatimSymlinks: true, + }); +} diff --git a/packages/stack/src/fleet/fleetLock.ts b/packages/stack/src/fleet/fleetLock.ts new file mode 100644 index 0000000000..d6433baef5 --- /dev/null +++ b/packages/stack/src/fleet/fleetLock.ts @@ -0,0 +1,73 @@ +import { mkdir, open, readFile, unlink } from "node:fs/promises"; +import { dirname } from "node:path"; + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + return typeof error.code === "string" ? error.code : undefined; +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export interface FleetLock { + release(): Promise; +} + +/** + * Fleet-wide single-owner lock: a pid file at the fleet root. + * + * The whole fleet design assumes exactly one daemon per root (PortRegistry has + * no cross-process coordination, and startup reconciliation KILLS any + * postmaster it finds under a pod dir — including ones a live sibling daemon + * owns). Acquiring this lock before touching any pod state turns "second + * daemon reaps the first daemon's warm pods, then dies on EADDRINUSE" into an + * immediate, explicit startup error. + * + * A lock whose recorded pid is no longer alive is stale (previous daemon + * crashed) and is taken over. The post-acquire read-back closes most of the + * window where two starters could both observe the same stale lock, unlink + * it, and race the re-create. + */ +export async function acquireFleetLock(lockPath: string): Promise { + await mkdir(dirname(lockPath), { recursive: true }); + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const handle = await open(lockPath, "wx"); + try { + await handle.writeFile(String(process.pid)); + } finally { + await handle.close(); + } + } catch (error) { + if (errorCode(error) !== "EEXIST") throw error; + const raw = await readFile(lockPath, "utf8").catch(() => undefined); + const ownerPid = Number(raw?.trim()); + // A live owner blocks acquisition even when it's this very process: + // two fleets in one process on the same root are still two daemons. + if (Number.isFinite(ownerPid) && ownerPid > 0 && isAlive(ownerPid)) { + throw new Error( + `another fleet daemon (pid ${ownerPid}) already owns this fleet root; refusing to start a second one against ${lockPath}`, + ); + } + await unlink(lockPath).catch(() => {}); + continue; + } + const written = await readFile(lockPath, "utf8").catch(() => undefined); + if (written?.trim() !== String(process.pid)) continue; // lost a takeover race + return { + async release() { + const owner = await readFile(lockPath, "utf8").catch(() => undefined); + if (owner?.trim() === String(process.pid)) { + await unlink(lockPath).catch(() => {}); + } + }, + }; + } + throw new Error(`could not acquire the fleet lock at ${lockPath}`); +} diff --git a/packages/stack/src/fleet/index.ts b/packages/stack/src/fleet/index.ts new file mode 100644 index 0000000000..1ae6e26d9e --- /dev/null +++ b/packages/stack/src/fleet/index.ts @@ -0,0 +1,2 @@ +export type { CreatePodOptions, FleetHandle, FleetOptions, PodState, PodStatus } from "./Fleet.ts"; +export { createFleet } from "./Fleet.ts"; diff --git a/packages/stack/src/fleet/index.unit.test.ts b/packages/stack/src/fleet/index.unit.test.ts new file mode 100644 index 0000000000..57f7fdd526 --- /dev/null +++ b/packages/stack/src/fleet/index.unit.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { createFleet } from "@supabase/stack/fleet"; + +describe("Fleet package export", () => { + it("is available from the Stack fleet subpath", () => { + expect(createFleet).toBeTypeOf("function"); + }); +}); diff --git a/packages/stack/src/fleet/podLock.ts b/packages/stack/src/fleet/podLock.ts new file mode 100644 index 0000000000..6c2017b721 --- /dev/null +++ b/packages/stack/src/fleet/podLock.ts @@ -0,0 +1,57 @@ +/** + * Per-key async operation lock: serializes `withLock` calls that share the + * same `id` onto a single chain, while calls for different ids run fully + * concurrently. + * + * Used by `Fleet` to make sure a pod's lifecycle operations (wake, suspend, + * destroy, reset, fork) never interleave against the same pod's data + * directory / postgres process — e.g. a wake racing an in-flight suspend + * could otherwise `createStack` against a data dir whose postmaster is + * still shutting down. + */ +export class PodLock { + private readonly chains = new Map>(); + + /** Number of ids currently holding a live (unsettled) chain entry. */ + get size(): number { + return this.chains.size; + } + + /** + * Runs `fn` after every previously chained op for `id` has settled + * (resolved OR rejected), and returns `fn`'s result/rejection. + * + * A rejection from `fn` (or from an earlier op in the chain) never + * poisons subsequent calls for the same `id` — the chain always advances + * to a resolved "tail" internally, regardless of whether the caller-visible + * promise for a given link rejects. + */ + async withLock(id: string, fn: () => Promise): Promise { + const prior = this.chains.get(id) ?? Promise.resolve(); + + // The tail settles once `prior` has settled, regardless of outcome, so + // the next `withLock` call for this id always proceeds. + const tail = prior.then( + () => undefined, + () => undefined, + ); + const result = tail.then(fn); + + // Keep the chain alive (swallow rejection here too) so the *next* link + // waits for this one without itself becoming a rejected chain entry. + const nextTail = result.then( + () => undefined, + () => undefined, + ); + this.chains.set(id, nextTail); + + // Once this op's link is the most recent one recorded and it has + // settled, clear the entry so the map doesn't grow unboundedly for pods + // that are no longer being operated on. + void nextTail.then(() => { + if (this.chains.get(id) === nextTail) this.chains.delete(id); + }); + + return result; + } +} diff --git a/packages/stack/src/fleet/podLock.unit.test.ts b/packages/stack/src/fleet/podLock.unit.test.ts new file mode 100644 index 0000000000..f0df27ce65 --- /dev/null +++ b/packages/stack/src/fleet/podLock.unit.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { PodLock } from "./podLock.ts"; + +describe("PodLock", () => { + it("serializes operations for the same id in call order", async () => { + const lock = new PodLock(); + const order: string[] = []; + + const first = lock.withLock("a", async () => { + order.push("first-start"); + await new Promise((r) => setTimeout(r, 20)); + order.push("first-end"); + return 1; + }); + const second = lock.withLock("a", async () => { + order.push("second-start"); + await new Promise((r) => setTimeout(r, 5)); + order.push("second-end"); + return 2; + }); + + expect(await first).toBe(1); + expect(await second).toBe(2); + expect(order).toEqual(["first-start", "first-end", "second-start", "second-end"]); + }); + + it("does not let a rejected op poison the chain for subsequent ops", async () => { + const lock = new PodLock(); + + await expect( + lock.withLock("a", async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + // A later op on the same id must still run (and run after the failure). + const order: string[] = []; + await lock.withLock("a", async () => { + order.push("ran"); + }); + expect(order).toEqual(["ran"]); + }); + + it("does not serialize operations across different ids", async () => { + const lock = new PodLock(); + const order: string[] = []; + + const a = lock.withLock("a", async () => { + order.push("a-start"); + await new Promise((r) => setTimeout(r, 30)); + order.push("a-end"); + }); + const b = lock.withLock("b", async () => { + order.push("b-start"); + await new Promise((r) => setTimeout(r, 5)); + order.push("b-end"); + }); + + await Promise.all([a, b]); + // b, on a different id, should complete before a (which sleeps longer), + // proving the two chains ran concurrently rather than serialized. + expect(order.indexOf("b-end")).toBeLessThan(order.indexOf("a-end")); + expect(order).toContain("a-start"); + expect(order).toContain("b-start"); + }); + + it("clears the internal chain entry once settled (no unbounded growth)", async () => { + const lock = new PodLock(); + await lock.withLock("a", async () => {}); + expect(lock.size).toBe(0); + }); + + it("preserves order across many interleaved ops on the same id", async () => { + const lock = new PodLock(); + const results: number[] = []; + const ops = Array.from({ length: 10 }, (_, i) => + lock.withLock("a", async () => { + await new Promise((r) => setTimeout(r, (10 - i) % 3)); + results.push(i); + }), + ); + await Promise.all(ops); + expect(results).toEqual(Array.from({ length: 10 }, (_, i) => i)); + }); +}); diff --git a/packages/stack/src/fleet/reapStalePostmaster.ts b/packages/stack/src/fleet/reapStalePostmaster.ts new file mode 100644 index 0000000000..833a1f8ebf --- /dev/null +++ b/packages/stack/src/fleet/reapStalePostmaster.ts @@ -0,0 +1,121 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { readFile, realpath } from "node:fs/promises"; +import { join } from "node:path"; + +const execFileAsync = promisify(execFile); +const TERM_TIMEOUT_MS = 5000; +const POLL_MS = 100; + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function commandForPid(pid: number): Promise { + const procCmdline = await readFile(`/proc/${pid}/cmdline`, "utf8") + .then((raw) => raw.split("\u0000").join(" ").trim()) + .catch(() => undefined); + if (procCmdline !== undefined && procCmdline !== "") return procCmdline; + + return execFileAsync("ps", ["-p", String(pid), "-o", "command="]) + .then(({ stdout }) => stdout.trim()) + .catch(() => undefined); +} + +async function isPostmasterForDataDir(pid: number, dataDir: string, raw: string): Promise { + const lines = raw.split("\n"); + const recordedDataDir = lines[1]?.trim(); + if (recordedDataDir === undefined || recordedDataDir === "") return false; + + const [actualDir, recordedDir] = await Promise.all([ + realpath(dataDir).catch(() => dataDir), + realpath(recordedDataDir).catch(() => recordedDataDir), + ]); + if (actualDir !== recordedDir) return false; + + const command = await commandForPid(pid); + return ( + command !== undefined && + command.includes("postgres") && + (command.includes(actualDir) || command.includes(recordedDataDir) || command.includes(dataDir)) + ); +} + +async function waitUntilExited(pid: number, timeoutMs: number): Promise { + const start = Date.now(); + while (isAlive(pid)) { + if (Date.now() - start >= timeoutMs) return false; + await new Promise((resolve) => setTimeout(resolve, POLL_MS)); + } + return true; +} + +function signalPostmaster(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(-pid, signal); + } catch { + try { + process.kill(pid, signal); + } catch { + /* already gone */ + } + } +} + +/** + * Reaps a stale postmaster (and its backends) left running under `dataDir` + * by a previous, no-longer-live daemon process. + * + * Ground truth is postgres's own `/postmaster.pid`: its FIRST LINE + * is the postmaster's pid. Unlike our `run.pid` marker (which records the + * *daemon's* pid, not postgres's), the postmaster is always its own + * process-group leader — its pgid equals its pid, and every backend it forks + * shares that pgid. So `kill(-pid, ...)` reliably reaches the whole + * postgres process tree for this data dir, regardless of what spawned it or + * how (`@supabase/stack`'s `createStack`, like process-compose's + * `detached: true` children, does not run postgres as a child of the fleet + * daemon's own process group). + * + * This is a crash-recovery fallback for Postgres. Normal pod suspension asks + * Stack to stop the complete service graph before checking that Postgres has + * exited; orphaned sidecars remain covered by Stack's persisted cleanup + * metadata rather than inferred from `postmaster.pid`. + */ +export async function reapStalePostmaster(dataDir: string): Promise { + const raw = await readFile(join(dataDir, "postmaster.pid"), "utf8").catch(() => undefined); + if (raw === undefined) return; + + const firstLine = raw.split("\n")[0]?.trim(); + const pid = Number(firstLine); + if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return; + + let alive = false; + try { + process.kill(pid, 0); + alive = true; + } catch { + alive = false; + } + if (!alive) return; + + if (!(await isPostmasterForDataDir(pid, dataDir, raw))) return; + + signalPostmaster(pid, "SIGTERM"); + if (await waitUntilExited(pid, TERM_TIMEOUT_MS)) return; + + // Re-verify before escalating: the 5s grace period is exactly the window + // where the postmaster may have exited on its own and the OS reused its + // pid for an unrelated process — SIGKILLing that would be unrecoverable. + if (!(await isPostmasterForDataDir(pid, dataDir, raw))) return; + + signalPostmaster(pid, "SIGKILL"); + if (await waitUntilExited(pid, TERM_TIMEOUT_MS)) return; + if (await isPostmasterForDataDir(pid, dataDir, raw)) { + throw new Error(`failed to terminate postgres process ${pid} for data directory ${dataDir}`); + } +} diff --git a/packages/stack/src/fleet/reapStalePostmaster.unit.test.ts b/packages/stack/src/fleet/reapStalePostmaster.unit.test.ts new file mode 100644 index 0000000000..793377050c --- /dev/null +++ b/packages/stack/src/fleet/reapStalePostmaster.unit.test.ts @@ -0,0 +1,100 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { reapStalePostmaster } from "./reapStalePostmaster.ts"; + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitUntil(predicate: () => boolean, timeoutMs = 5000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("timed out waiting for condition"); + await new Promise((r) => setTimeout(r, 50)); + } +} + +describe("reapStalePostmaster", () => { + const dirs: string[] = []; + const pidsToCleanUp: number[] = []; + + afterEach(async () => { + for (const pid of pidsToCleanUp.splice(0)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + /* already gone */ + } + } + for (const dir of dirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("kills a live detached process group referenced by postmaster.pid", async () => { + const dir = await mkdtemp(join(tmpdir(), "reap-test-")); + dirs.push(dir); + + const child = spawn("bash", ["-c", `exec -a "postgres -D ${dir}" sleep 60`], { + detached: true, + stdio: "ignore", + }); + child.unref(); + const pid = child.pid; + if (pid === undefined) throw new Error("failed to spawn dummy process"); + pidsToCleanUp.push(pid); + + await writeFile(join(dir, "postmaster.pid"), `${pid}\n${dir}\n1234567\n5432\n`); + + expect(isAlive(pid)).toBe(true); + await reapStalePostmaster(dir); + await waitUntil(() => !isAlive(pid)); + expect(isAlive(pid)).toBe(false); + }, 10_000); + + it("is a no-op when postmaster.pid is missing", async () => { + const dir = await mkdtemp(join(tmpdir(), "reap-test-")); + dirs.push(dir); + await expect(reapStalePostmaster(dir)).resolves.toBeUndefined(); + }); + + it("is a no-op when postmaster.pid contains garbage", async () => { + const dir = await mkdtemp(join(tmpdir(), "reap-test-")); + dirs.push(dir); + await writeFile(join(dir, "postmaster.pid"), "not-a-pid\nnoise\n"); + await expect(reapStalePostmaster(dir)).resolves.toBeUndefined(); + }); + + it("refuses to kill when the pid equals the current process pid", async () => { + const dir = await mkdtemp(join(tmpdir(), "reap-test-")); + dirs.push(dir); + await writeFile(join(dir, "postmaster.pid"), `${process.pid}\n/some/data/dir\n`); + await expect(reapStalePostmaster(dir)).resolves.toBeUndefined(); + // Sanity: we're still alive (obviously true, but documents intent). + expect(isAlive(process.pid)).toBe(true); + }); + + it("refuses to kill a reused pid that is not this postmaster", async () => { + const dir = await mkdtemp(join(tmpdir(), "reap-test-")); + dirs.push(dir); + + const child = spawn("sleep", ["60"], { detached: true, stdio: "ignore" }); + child.unref(); + const pid = child.pid; + if (pid === undefined) throw new Error("failed to spawn dummy process"); + pidsToCleanUp.push(pid); + + await writeFile(join(dir, "postmaster.pid"), `${pid}\n${dir}\n1234567\n5432\n`); + + await reapStalePostmaster(dir); + expect(isAlive(pid)).toBe(true); + }); +}); diff --git a/packages/stack/src/functions.ts b/packages/stack/src/functions.ts index 527a163b65..caaa0d7f5e 100644 --- a/packages/stack/src/functions.ts +++ b/packages/stack/src/functions.ts @@ -8,6 +8,7 @@ import { type ResolvedFunctionConfig, } from "@supabase/config"; import { Effect, FileSystem, Path, Redacted } from "effect"; +import { postgresConnectionUrl } from "./postgresCredentials.ts"; import type { ResolvedStackConfig } from "./StackBuilder.ts"; export interface FunctionsConfig { @@ -194,7 +195,13 @@ export const resolveFunctionsRuntimeConfig = Effect.fnUntraced(function* ( return { functionsUrl: `http://127.0.0.1:${stackConfig.apiPort}/functions/v1`, supabaseUrl: `http://${runtimeHost.hostname}:${stackConfig.apiPort}`, - dbUrl: `postgresql://postgres:postgres@${runtimeHost.hostname}:${stackConfig.dbPort}/postgres`, + dbUrl: postgresConnectionUrl({ + user: "postgres", + password: stackConfig.postgres.password, + host: runtimeHost.hostname, + port: stackConfig.dbPort, + database: "postgres", + }), publishableKey: stackConfig.publishableKey, secretKey: stackConfig.secretKey, jwtSecret: stackConfig.jwtSecret, diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index a744b173ba..e315688564 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -60,7 +60,9 @@ describe("stack Functions runtime config", () => { return Effect.gen(function* () { yield* Effect.promise(() => writeProject(cwd)); - const stackConfig = yield* Effect.promise(() => resolveConfig({ projectDir: cwd })); + const stackConfig = yield* Effect.promise(() => + resolveConfig({ projectDir: cwd, services: ["edge-runtime"] }), + ); const config = yield* resolveFunctionsRuntimeConfig(stackConfig, { hostname: "127.0.0.1", }); @@ -93,6 +95,7 @@ describe("stack Functions runtime config", () => { const stackConfig = yield* Effect.promise(() => resolveConfig({ projectDir: cwd, + services: ["edge-runtime"], functions: { envFile: "custom.env", noVerifyJwt: true, @@ -135,7 +138,9 @@ describe("stack Functions runtime config", () => { return Effect.gen(function* () { yield* Effect.promise(() => writeProject(cwd)); - const stackConfig = yield* Effect.promise(() => resolveConfig({ projectDir: cwd })); + const stackConfig = yield* Effect.promise(() => + resolveConfig({ projectDir: cwd, services: ["edge-runtime"] }), + ); yield* configureFunctionsRuntime(stackConfig, { hostname: "127.0.0.1" }); const written = JSON.parse( yield* Effect.promise(() => diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 087b40503e..448ddb409a 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -20,9 +20,20 @@ export type { VectorConfig, } from "./StackBuilder.ts"; -export type { ServiceName, VersionManifest } from "./versions.ts"; +export type { ServiceName, StackServiceName, VersionManifest } from "./versions.ts"; +export { + DEFAULT_VERSIONS, + fillServiceVersionManifest, + SERVICE_NAMES, + STACK_SERVICE_NAMES, +} from "./versions.ts"; +export type { AllocatedPorts } from "./PortAllocator.ts"; +export { PORT_FIELDS } from "./PortAllocator.ts"; +export { postgresConnectionUrl, resolvePostgresPassword } from "./postgresCredentials.ts"; +export { validateEnabledServiceDependencies } from "./serviceDependencies.ts"; export type { ServiceResolution } from "./resolve.ts"; export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; export type { ReadyOptions, StackHandle } from "./createStack.ts"; export type { FunctionsConfig, FunctionsRuntimeConfig } from "./functions.ts"; export { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; +export { installMicroProfile } from "./pgconf.ts"; diff --git a/packages/stack/src/layers.ts b/packages/stack/src/layers.ts index 7cc36fb504..102c835cf4 100644 --- a/packages/stack/src/layers.ts +++ b/packages/stack/src/layers.ts @@ -1,11 +1,12 @@ import { fork, type ChildProcess } from "node:child_process"; import { Data, Effect, Layer, Option } from "effect"; import { FileSystem, Path } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; +import { FetchHttpClient, HttpClient, HttpServer } from "effect/unstable/http"; import { ApiProxy, type ProxyConfig } from "./ApiProxy.ts"; import { BinaryResolver } from "./BinaryResolver.ts"; import type { PlatformFactory } from "./createStack.ts"; import type { DaemonMessage, DaemonStartMessage } from "./daemon.ts"; +import { makeEnsureServiceMemo } from "./lazyServices.ts"; import { RemoteStack } from "./RemoteStack.ts"; import { Stack } from "./Stack.ts"; import { StackLifecycleCoordinator } from "./StackLifecycleCoordinator.ts"; @@ -20,10 +21,54 @@ import { type StateManagerService, } from "./StateManager.ts"; import { StackBuilder, type ResolvedStackConfig } from "./StackBuilder.ts"; +import type { ServiceName } from "./versions.ts"; import { UnixHttpClient } from "./UnixHttpClient.ts"; import { resolveManagedStack } from "./managed-stack.ts"; import { terminateChildProcess } from "./terminateChild.ts"; +const baseProxyConfig = (config: ResolvedStackConfig): Omit => ({ + listenPort: config.apiPort, + gotruePort: config.auth !== false ? config.auth.port : 0, + postgrestPort: config.postgrest !== false ? config.postgrest.port : 0, + postgrestAdminPort: config.postgrest !== false ? config.postgrest.adminPort : 0, + edgeRuntimePort: config.edgeRuntime !== false ? config.edgeRuntime.port : 0, + realtimePort: config.realtime !== false ? config.realtime.port : 0, + storagePort: config.storage !== false ? config.storage.port : 0, + pgmetaPort: config.pgmeta !== false ? config.pgmeta.port : 0, + analyticsPort: config.analytics !== false ? config.analytics.port : 0, + poolerPort: config.pooler !== false ? config.pooler.apiPort : 0, + studioPort: config.studio !== false ? config.studio.port : 0, + imgproxyEnabled: config.imgproxy !== false, + publishableKey: config.publishableKey, + secretKey: config.secretKey, + anonJwt: config.anonJwt, + serviceRoleJwt: config.serviceRoleJwt, +}); + +/** + * Build the ApiProxy layer for a stack. + * + * The layer depends on `Stack` so it can wire `ProxyConfig.ensureService` to + * `Stack.startService`, memoized so concurrent first requests trigger one preparation/start. + */ +const makeApiProxyLayer = ( + config: ResolvedStackConfig, +): Layer.Layer => { + return Layer.unwrap( + Effect.gen(function* () { + const stack = yield* Stack; + const ensureService = makeEnsureServiceMemo((name: ServiceName) => + Effect.runPromise( + Effect.gen(function* () { + yield* stack.startService(name); + }), + ), + ); + return ApiProxy.layer({ ...baseProxyConfig(config), ensureService }); + }), + ); +}; + /** * Build a foreground layer that runs the stack in-process. * @@ -47,24 +92,10 @@ export const foregroundLayer = ( ); const stackLayer = Stack.layer(config).pipe(Layer.provide(coordinatorLayer)); - const proxyConfig: ProxyConfig = { - listenPort: config.apiPort, - gotruePort: config.auth !== false ? config.auth.port : 0, - postgrestPort: config.postgrest !== false ? config.postgrest.port : 0, - postgrestAdminPort: config.postgrest !== false ? config.postgrest.adminPort : 0, - edgeRuntimePort: config.edgeRuntime !== false ? config.edgeRuntime.port : 0, - realtimePort: config.realtime !== false ? config.realtime.port : 0, - storagePort: config.storage !== false ? config.storage.port : 0, - pgmetaPort: config.pgmeta !== false ? config.pgmeta.port : 0, - analyticsPort: config.analytics !== false ? config.analytics.port : 0, - poolerPort: config.pooler !== false ? config.pooler.apiPort : 0, - studioPort: config.studio !== false ? config.studio.port : 0, - publishableKey: config.publishableKey, - secretKey: config.secretKey, - anonJwt: config.anonJwt, - serviceRoleJwt: config.serviceRoleJwt, - }; - const apiProxyLayer = ApiProxy.layer(proxyConfig).pipe(Layer.provide(FetchHttpClient.layer)); + const apiProxyLayer = makeApiProxyLayer(config).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(stackLayer), + ); return Layer.mergeAll(stackLayer, apiProxyLayer).pipe(Layer.provide(platform), Layer.orDie); }; @@ -95,24 +126,6 @@ export const foregroundDaemonLayer = ( const binaryResolverLayer = BinaryResolver.make(config.cacheRoot).pipe( Layer.provide(FetchHttpClient.layer), ); - const proxyConfig: ProxyConfig = { - listenPort: config.apiPort, - gotruePort: config.auth !== false ? config.auth.port : 0, - postgrestPort: config.postgrest !== false ? config.postgrest.port : 0, - postgrestAdminPort: config.postgrest !== false ? config.postgrest.adminPort : 0, - edgeRuntimePort: config.edgeRuntime !== false ? config.edgeRuntime.port : 0, - realtimePort: config.realtime !== false ? config.realtime.port : 0, - storagePort: config.storage !== false ? config.storage.port : 0, - pgmetaPort: config.pgmeta !== false ? config.pgmeta.port : 0, - analyticsPort: config.analytics !== false ? config.analytics.port : 0, - poolerPort: config.pooler !== false ? config.pooler.apiPort : 0, - studioPort: config.studio !== false ? config.studio.port : 0, - publishableKey: config.publishableKey, - secretKey: config.secretKey, - anonJwt: config.anonJwt, - serviceRoleJwt: config.serviceRoleJwt, - }; - const apiProxyLayer = ApiProxy.layer(proxyConfig).pipe(Layer.provide(FetchHttpClient.layer)); const stateManagerLayer = StateManager.make( singleStackStateManagerPaths(config.stackRoot, config.runtimeRoot, config.name), ); @@ -127,6 +140,11 @@ export const foregroundDaemonLayer = ( ); const stackLayer = Stack.layer(config).pipe(Layer.provide(coordinatorLayer)); + const apiProxyLayer = makeApiProxyLayer(config).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(stackLayer), + ); + return Layer.mergeAll(stackLayer, apiProxyLayer, stateManagerLayer).pipe( Layer.provide(platform), Layer.orDie, diff --git a/packages/stack/src/lazyServices.ts b/packages/stack/src/lazyServices.ts new file mode 100644 index 0000000000..8dc7a2da7d --- /dev/null +++ b/packages/stack/src/lazyServices.ts @@ -0,0 +1,32 @@ +import type { ServiceName } from "./versions.ts"; + +/** + * Wrap a service-start function so concurrent callers for the same service + * share a single in-flight start. + * + * On failure, the in-flight entry is cleared (not marked done) so the next + * call retries from scratch. Successful starts are not cached permanently: + * process-compose startService is idempotent for already-running services, and + * rechecking lets a service that was later stopped be started by the next + * proxied request. + */ +export const makeEnsureServiceMemo = ( + start: (name: ServiceName) => Promise, +): ((name: ServiceName) => Promise) => { + const inFlight = new Map>(); + return (name) => { + const existing = inFlight.get(name); + if (existing) return existing; + const p = start(name).then( + () => { + inFlight.delete(name); + }, + (err: unknown) => { + inFlight.delete(name); + throw err; + }, + ); + inFlight.set(name, p); + return p; + }; +}; diff --git a/packages/stack/src/lazyServices.unit.test.ts b/packages/stack/src/lazyServices.unit.test.ts new file mode 100644 index 0000000000..2f5d1723db --- /dev/null +++ b/packages/stack/src/lazyServices.unit.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { makeEnsureServiceMemo } from "./lazyServices.ts"; + +describe("makeEnsureServiceMemo", () => { + it("starts a service once across concurrent calls", async () => { + let starts = 0; + const ensure = makeEnsureServiceMemo(async (_name) => { + starts += 1; + await new Promise((r) => setTimeout(r, 10)); + }); + await Promise.all([ensure("realtime"), ensure("realtime"), ensure("realtime")]); + expect(starts).toBe(1); + }); + + it("retries after a failed start", async () => { + let attempt = 0; + const ensure = makeEnsureServiceMemo(async () => { + attempt += 1; + if (attempt === 1) throw new Error("boom"); + }); + await expect(ensure("auth")).rejects.toThrow("boom"); + await ensure("auth"); // second attempt allowed + expect(attempt).toBe(2); + }); + + it("checks again after a completed start", async () => { + let starts = 0; + const ensure = makeEnsureServiceMemo(async (_name) => { + starts += 1; + }); + await ensure("storage"); + await ensure("storage"); + await ensure("storage"); + expect(starts).toBe(3); + }); + + it("tracks each service name independently", async () => { + const seen: string[] = []; + const ensure = makeEnsureServiceMemo(async (name) => { + seen.push(name); + }); + await Promise.all([ensure("realtime"), ensure("storage")]); + expect(seen.sort()).toEqual(["realtime", "storage"]); + }); +}); diff --git a/packages/stack/src/micro.ts b/packages/stack/src/micro.ts new file mode 100644 index 0000000000..c22a891ea9 --- /dev/null +++ b/packages/stack/src/micro.ts @@ -0,0 +1,52 @@ +/** + * Micro profile: normative values from + * docs/specs/2026-07-07-micro-supabase-stacks-design.md ("The micro.conf profile"). + */ +export const MICRO_POSTGRES_SETTINGS: ReadonlyArray = [ + // memory + ["shared_buffers", "16MB"], + ["work_mem", "4MB"], + ["maintenance_work_mem", "32MB"], + ["jit", "off"], + ["huge_pages", "off"], + ["max_connections", "40"], + // background CPU + ["autovacuum_naptime", "5min"], + ["autovacuum_max_workers", "1"], + ["bgwriter_lru_maxpages", "0"], + ["wal_writer_delay", "10s"], + ["checkpoint_timeout", "30min"], + ["max_parallel_workers", "0"], + ["max_parallel_workers_per_gather", "0"], + ["max_worker_processes", "4"], + ["track_io_timing", "off"], + // durability (disposable profile) + ["fsync", "off"], + ["synchronous_commit", "off"], + ["full_page_writes", "off"], + // replication reservations + ["wal_level", "logical"], + ["max_wal_senders", "5"], + ["max_replication_slots", "5"], + ["max_slot_wal_keep_size", "256MB"], + ["wal_keep_size", "0"], +]; + +export const buildMicroConf = (): string => + `${MICRO_POSTGRES_SETTINGS.map(([k, v]) => `${k} = '${v}'`).join("\n")}\n`; + +/** Extensions that only work when named in shared_preload_libraries. */ +export const PRELOAD_REQUIRED_EXTENSIONS: ReadonlySet = new Set([ + "pg_cron", + "pg_net", + "timescaledb", + "pg_stat_statements", + "auto_explain", + "pgaudit", + "plan_filter", + "supautils", + "pgsodium", +]); + +export const buildPodConf = (preloadLibraries: ReadonlyArray): string => + `shared_preload_libraries = '${preloadLibraries.join(",")}'\n`; diff --git a/packages/stack/src/micro.unit.test.ts b/packages/stack/src/micro.unit.test.ts new file mode 100644 index 0000000000..8bb928e636 --- /dev/null +++ b/packages/stack/src/micro.unit.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + buildMicroConf, + buildPodConf, + MICRO_POSTGRES_SETTINGS, + PRELOAD_REQUIRED_EXTENSIONS, +} from "./micro.ts"; + +describe("micro profile", () => { + it("contains the normative spec settings", () => { + const map = new Map(MICRO_POSTGRES_SETTINGS); + expect(map.get("shared_buffers")).toBe("16MB"); + expect(map.get("jit")).toBe("off"); + expect(map.get("fsync")).toBe("off"); + expect(map.get("wal_level")).toBe("logical"); + expect(map.get("max_slot_wal_keep_size")).toBe("256MB"); + expect(map.get("wal_writer_delay")).toBe("10s"); + }); + + it("renders micro.conf as key = 'value' lines", () => { + const conf = buildMicroConf(); + expect(conf).toContain("shared_buffers = '16MB'"); + expect(conf).toContain("max_connections = '40'"); + expect(conf.endsWith("\n")).toBe(true); + }); + + it("knows which extensions need preload", () => { + expect(PRELOAD_REQUIRED_EXTENSIONS.has("pg_cron")).toBe(true); + expect(PRELOAD_REQUIRED_EXTENSIONS.has("pgvector")).toBe(false); + }); + + it("renders pod.conf with shared_preload_libraries", () => { + expect(buildPodConf(["pg_cron", "pg_net"])).toBe( + "shared_preload_libraries = 'pg_cron,pg_net'\n", + ); + expect(buildPodConf([])).toBe("shared_preload_libraries = ''\n"); + }); +}); diff --git a/packages/stack/src/node.ts b/packages/stack/src/node.ts index fb7a6eb412..31eb8b554c 100644 --- a/packages/stack/src/node.ts +++ b/packages/stack/src/node.ts @@ -8,7 +8,7 @@ import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { BinaryResolver } from "./BinaryResolver.ts"; import { - createStack as createStackCore, + createReadyStack as createStackCore, type PlatformFactory, type StackHandle, } from "./createStack.ts"; diff --git a/packages/stack/src/pgconf.ts b/packages/stack/src/pgconf.ts new file mode 100644 index 0000000000..2448dde99f --- /dev/null +++ b/packages/stack/src/pgconf.ts @@ -0,0 +1,84 @@ +import { readFile, rename, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { buildMicroConf, buildPodConf } from "./micro.ts"; + +/** + * All conf writes go through a temp-file + rename so a crash mid-write can + * never leave a truncated postgresql.conf/pod.conf behind — postgres would + * fail to boot (or silently drop preload libraries) on the next start. + */ +async function writeFileAtomic(path: string, content: string): Promise { + const tmp = `${path}.tmp-${process.pid}-${Date.now()}`; + await writeFile(tmp, content); + await rename(tmp, path); +} + +const INCLUDE_BLOCK = [ + "", + "# --- supabase micro profile (managed; do not edit below) ---", + "include_if_exists = 'micro.conf'", + "include_if_exists = 'pod.conf'", + "", +].join("\n"); + +// Line-anchored, active (non-commented) include directive only — a commented-out +// line like `#include_if_exists = 'micro.conf'` must NOT count as installed. +const ACTIVE_INCLUDE_RE = /^\s*include_if_exists = 'micro\.conf'/m; +const ACTIVE_POD_INCLUDE_RE = /^\s*include_if_exists = 'pod\.conf'/m; + +const PRELOAD_LIBRARIES_RE = /^\s*shared_preload_libraries\s*=\s*['"]([^'"]*)['"]/m; + +export async function installMicroProfile(pgdata: string): Promise { + await writeFileAtomic(join(pgdata, "micro.conf"), buildMicroConf()); + const podConf = join(pgdata, "pod.conf"); + const existing = await readFile(podConf, "utf8").catch(() => undefined); + if (existing === undefined) { + await writeFileAtomic(podConf, buildPodConf([])); + } + const mainPath = join(pgdata, "postgresql.conf"); + const main = await readFile(mainPath, "utf8"); + if (!ACTIVE_INCLUDE_RE.test(main)) { + await writeFileAtomic(mainPath, main + INCLUDE_BLOCK); + } +} + +export async function installPodConfOverlay(pgdata: string): Promise { + const podConf = join(pgdata, "pod.conf"); + const existing = await readFile(podConf, "utf8").catch(() => undefined); + if (existing === undefined) { + await writeFileAtomic(podConf, buildPodConf([])); + } + const mainPath = join(pgdata, "postgresql.conf"); + const main = await readFile(mainPath, "utf8"); + if (!ACTIVE_POD_INCLUDE_RE.test(main)) { + const separator = main.endsWith("\n") || main === "" ? "" : "\n"; + await writeFileAtomic(mainPath, `${main}${separator}include_if_exists = 'pod.conf'\n`); + } +} + +export async function readPreloadLibraries(pgdata: string): Promise { + const content = await readFile(join(pgdata, "pod.conf"), "utf8").catch(() => ""); + const match = content.match(PRELOAD_LIBRARIES_RE); + if (!match || match[1] === undefined || match[1] === "") return []; + return match[1].split(",").map((lib) => lib.trim()); +} + +export async function writePreloadLibraries( + pgdata: string, + libs: ReadonlyArray, +): Promise { + const podConf = join(pgdata, "pod.conf"); + const existing = await readFile(podConf, "utf8").catch(() => undefined); + const line = `shared_preload_libraries = '${libs.join(",")}'`; + if (existing === undefined) { + await writeFileAtomic(podConf, `${line}\n`); + return; + } + if (PRELOAD_LIBRARIES_RE.test(existing)) { + const updated = existing.replace(PRELOAD_LIBRARIES_RE, line); + await writeFileAtomic(podConf, updated); + return; + } + const separator = existing.endsWith("\n") || existing === "" ? "" : "\n"; + await writeFileAtomic(podConf, `${existing}${separator}${line}\n`); +} diff --git a/packages/stack/src/pgconf.unit.test.ts b/packages/stack/src/pgconf.unit.test.ts new file mode 100644 index 0000000000..408f99d9b0 --- /dev/null +++ b/packages/stack/src/pgconf.unit.test.ts @@ -0,0 +1,100 @@ +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + installMicroProfile, + installPodConfOverlay, + readPreloadLibraries, + writePreloadLibraries, +} from "./pgconf.ts"; + +async function fakePgdata(): Promise { + const dir = await mkdtemp(join(tmpdir(), "pgconf-test-")); + await writeFile(join(dir, "postgresql.conf"), "# stock conf\nport = 5432\n"); + return dir; +} + +describe("pgconf", () => { + it("installs micro.conf, pod.conf, and include lines idempotently", async () => { + const pgdata = await fakePgdata(); + await installMicroProfile(pgdata); + await installMicroProfile(pgdata); // idempotent + const main = await readFile(join(pgdata, "postgresql.conf"), "utf8"); + expect(main.match(/include_if_exists = 'micro\.conf'/g)).toHaveLength(1); + expect(main.match(/include_if_exists = 'pod\.conf'/g)).toHaveLength(1); + // pod.conf must be included AFTER micro.conf so pod overrides micro + expect(main.indexOf("micro.conf")).toBeLessThan(main.indexOf("pod.conf")); + const micro = await readFile(join(pgdata, "micro.conf"), "utf8"); + expect(micro).toContain("shared_buffers = '16MB'"); + }); + + it("installs only the pod.conf overlay for default stacks", async () => { + const pgdata = await fakePgdata(); + await installPodConfOverlay(pgdata); + await installPodConfOverlay(pgdata); + + const main = await readFile(join(pgdata, "postgresql.conf"), "utf8"); + expect(main).toContain("include_if_exists = 'pod.conf'"); + expect(main).not.toContain("include_if_exists = 'micro.conf'"); + expect(main.match(/include_if_exists = 'pod\.conf'/g)).toHaveLength(1); + }); + + it("round-trips preload libraries via pod.conf", async () => { + const pgdata = await fakePgdata(); + await installMicroProfile(pgdata); + expect(await readPreloadLibraries(pgdata)).toEqual([]); + await writePreloadLibraries(pgdata, ["pg_cron"]); + expect(await readPreloadLibraries(pgdata)).toEqual(["pg_cron"]); + await writePreloadLibraries(pgdata, ["pg_cron", "pg_net"]); + expect(await readPreloadLibraries(pgdata)).toEqual(["pg_cron", "pg_net"]); + }); + + it("appends an active include block when only a commented-out include line exists", async () => { + const pgdata = await fakePgdata(); + const mainPath = join(pgdata, "postgresql.conf"); + await writeFile(mainPath, "# stock conf\nport = 5432\n#include_if_exists = 'micro.conf'\n"); + await installMicroProfile(pgdata); + const main = await readFile(mainPath, "utf8"); + expect(main.match(/^include_if_exists = 'micro\.conf'$/m)).toHaveLength(1); + expect(main.match(/^include_if_exists = 'pod\.conf'$/m)).toHaveLength(1); + // the commented-out line must remain untouched + expect(main).toContain("#include_if_exists = 'micro.conf'"); + }); + + it("parses shared_preload_libraries with flexible spacing and quoting", async () => { + const pgdata = await fakePgdata(); + await writeFile(join(pgdata, "pod.conf"), "shared_preload_libraries='pg_cron, pg_net'\n"); + expect(await readPreloadLibraries(pgdata)).toEqual(["pg_cron", "pg_net"]); + + await writeFile(join(pgdata, "pod.conf"), ' shared_preload_libraries = "pg_cron,pg_net"\n'); + expect(await readPreloadLibraries(pgdata)).toEqual(["pg_cron", "pg_net"]); + }); + + it("writePreloadLibraries preserves other settings already in pod.conf", async () => { + const pgdata = await fakePgdata(); + const podPath = join(pgdata, "pod.conf"); + await writeFile(podPath, "other_setting = 'foo'\nshared_preload_libraries = 'pg_cron'\n"); + await writePreloadLibraries(pgdata, ["pg_cron", "pg_net"]); + const pod = await readFile(podPath, "utf8"); + expect(pod).toContain("other_setting = 'foo'"); + expect(pod).toMatch(/^shared_preload_libraries = 'pg_cron,pg_net'$/m); + expect(await readPreloadLibraries(pgdata)).toEqual(["pg_cron", "pg_net"]); + }); + + it("writePreloadLibraries appends the line when pod.conf has no existing preload line", async () => { + const pgdata = await fakePgdata(); + const podPath = join(pgdata, "pod.conf"); + await writeFile(podPath, "other_setting = 'foo'\n"); + await writePreloadLibraries(pgdata, ["pg_net"]); + const pod = await readFile(podPath, "utf8"); + expect(pod).toContain("other_setting = 'foo'"); + expect(pod).toMatch(/^shared_preload_libraries = 'pg_net'$/m); + }); + + it("writePreloadLibraries creates pod.conf when missing", async () => { + const pgdata = await fakePgdata(); + await writePreloadLibraries(pgdata, ["pg_cron"]); + expect(await readPreloadLibraries(pgdata)).toEqual(["pg_cron"]); + }); +}); diff --git a/packages/stack/src/postgresCredentials.ts b/packages/stack/src/postgresCredentials.ts new file mode 100644 index 0000000000..43fca3d11b --- /dev/null +++ b/packages/stack/src/postgresCredentials.ts @@ -0,0 +1,14 @@ +const DEFAULT_POSTGRES_PASSWORD = "postgres"; + +export const resolvePostgresPassword = (password?: string): string => + password ?? process.env.POSTGRES_PASSWORD ?? DEFAULT_POSTGRES_PASSWORD; + +export const postgresConnectionUrl = (opts: { + readonly scheme?: "postgresql" | "ecto"; + readonly user: string; + readonly password: string; + readonly host: string; + readonly port: number; + readonly database: string; +}): string => + `${opts.scheme ?? "postgresql"}://${opts.user}:${encodeURIComponent(opts.password)}@${opts.host}:${opts.port}/${opts.database}`; diff --git a/packages/stack/src/prefetch.ts b/packages/stack/src/prefetch.ts index f086bd5102..dee49576b3 100644 --- a/packages/stack/src/prefetch.ts +++ b/packages/stack/src/prefetch.ts @@ -1,8 +1,10 @@ import { Effect } from "effect"; -import type { ChecksumMismatchError } from "./errors.ts"; -import type { DockerPullError } from "./errors.ts"; -import { type PreparedStackArtifacts, type StackPreparationInput } from "./StackPreparation.ts"; -import { StackPreparation } from "./StackPreparation.ts"; +import { + StackPreparation, + type PreparedStackArtifacts, + type StackPreparationError, + type StackPreparationInput, +} from "./StackPreparation.ts"; import type { ServiceResolution } from "./resolve.ts"; export interface PrefetchOptions extends StackPreparationInput {} @@ -14,7 +16,7 @@ const toPrefetchResult = (artifacts: PreparedStackArtifacts): PrefetchResult => export const prefetch = ( options?: PrefetchOptions, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const preparation = yield* StackPreparation; return yield* preparation.prepare(options).pipe(Effect.map(toPrefetchResult)); diff --git a/packages/stack/src/prefetch.unit.test.ts b/packages/stack/src/prefetch.unit.test.ts index 1cf509d074..1e66f02796 100644 --- a/packages/stack/src/prefetch.unit.test.ts +++ b/packages/stack/src/prefetch.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { Deferred, Effect, Layer, Sink, Stream } from "effect"; +import { Deferred, Effect, Exit, Layer, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; import { BinaryResolver } from "./BinaryResolver.ts"; @@ -301,4 +301,46 @@ describe("prefetch", () => { }); expect(resolver.resolved).toEqual([]); }); + + test("native mode fails instead of falling back to docker when a binary is missing", async () => { + const resolver = mockBinaryResolver({ failServices: ["auth"] }); + const spawner = mockSequenceSpawner([]); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const resolverService = yield* BinaryResolver; + const spawnerService = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* Effect.exit( + prepareAssetsWithDependencies(resolverService, spawnerService, { + mode: "native", + services: ["auth"], + }), + ); + }).pipe(Effect.provide(resolver.layer), Effect.provide(spawner.layer)), + ); + + expect(Exit.isFailure(result)).toBe(true); + expect(spawner.spawned).toEqual([]); + }); + + test("native mode rejects docker-only services", async () => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner([]); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const resolverService = yield* BinaryResolver; + const spawnerService = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* Effect.exit( + prepareAssetsWithDependencies(resolverService, spawnerService, { + mode: "native", + services: ["realtime"], + }), + ); + }).pipe(Effect.provide(resolver.layer), Effect.provide(spawner.layer)), + ); + + expect(Exit.isFailure(result)).toBe(true); + expect(spawner.spawned).toEqual([]); + }); }); diff --git a/packages/stack/src/provisioned-bun.ts b/packages/stack/src/provisioned-bun.ts new file mode 100644 index 0000000000..d9f2392ba0 --- /dev/null +++ b/packages/stack/src/provisioned-bun.ts @@ -0,0 +1,17 @@ +import { createReadyStack, type StackHandle } from "./createStack.ts"; +import { platformFactory } from "./bun.ts"; +import { provisionedStackConfig, type ProvisionedStackOptions } from "./provisionedStack.ts"; + +export async function createProvisionedStack( + options: ProvisionedStackOptions, +): Promise { + return createReadyStack(provisionedStackConfig(options), platformFactory); +} + +export type { + ProvisionedServiceName, + ProvisionedStackOptions, + ProvisionedStackVersions, +} from "./provisionedStack.ts"; +export { configureExtensionPreload } from "./extensionPreload.ts"; +export type { ExtensionPreloadResult } from "./extensionPreload.ts"; diff --git a/packages/stack/src/provisioned-node.ts b/packages/stack/src/provisioned-node.ts new file mode 100644 index 0000000000..029aa92cfb --- /dev/null +++ b/packages/stack/src/provisioned-node.ts @@ -0,0 +1,17 @@ +import { createReadyStack, type StackHandle } from "./createStack.ts"; +import { platformFactory } from "./node.ts"; +import { provisionedStackConfig, type ProvisionedStackOptions } from "./provisionedStack.ts"; + +export async function createProvisionedStack( + options: ProvisionedStackOptions, +): Promise { + return createReadyStack(provisionedStackConfig(options), platformFactory); +} + +export type { + ProvisionedServiceName, + ProvisionedStackOptions, + ProvisionedStackVersions, +} from "./provisionedStack.ts"; +export { configureExtensionPreload } from "./extensionPreload.ts"; +export type { ExtensionPreloadResult } from "./extensionPreload.ts"; diff --git a/packages/stack/src/provisionedStack.ts b/packages/stack/src/provisionedStack.ts new file mode 100644 index 0000000000..fe650ac4d3 --- /dev/null +++ b/packages/stack/src/provisionedStack.ts @@ -0,0 +1,78 @@ +import { validateEnabledServiceDependencies } from "./serviceDependencies.ts"; +import type { FunctionsConfig } from "./functions.ts"; +import type { StackConfig } from "./StackBuilder.ts"; +import type { ServiceName, VersionManifest } from "./versions.ts"; + +export type ProvisionedServiceName = Exclude; + +export type ProvisionedStackVersions = Pick & + Partial>; + +/** + * The complete interface required to run a pre-initialized micro-profile data + * directory. Callers describe the pod; @supabase/stack owns how that becomes a + * StackConfig, including native mode, service disabling, and version mapping. + */ +export interface ProvisionedStackOptions { + readonly dataDir: string; + readonly postgresPassword: string; + readonly versions: ProvisionedStackVersions; + readonly services?: ReadonlyArray; + readonly stackRoot?: string; + readonly startServices?: ReadonlyArray; + readonly projectDir?: string; + readonly jwtSecret?: string; + readonly publishableKey?: string; + readonly secretKey?: string; + readonly functions?: FunctionsConfig | false; +} + +function versionedService( + name: ProvisionedServiceName, + options: ProvisionedStackOptions, +): { readonly version: string } | undefined { + if (!options.services?.includes(name)) return undefined; + const version = options.versions[name]; + if (version === undefined) { + throw new Error(`versions.${name} is required when ${name} is enabled`); + } + return { version }; +} + +/** Internal resolver kept separate so the deep public constructor stays easy to verify. */ +export function provisionedStackConfig(options: ProvisionedStackOptions): StackConfig { + const enabledServices = new Set(options.services ?? []); + const dependencyError = validateEnabledServiceDependencies(enabledServices); + if (dependencyError !== undefined) throw new Error(dependencyError); + + return { + mode: "auto", + stackRoot: options.stackRoot, + startServices: options.startServices, + projectDir: options.projectDir, + jwtSecret: options.jwtSecret, + publishableKey: options.publishableKey, + secretKey: options.secretKey, + services: options.services, + postgres: { + dataDir: options.dataDir, + version: options.versions.postgres, + password: options.postgresPassword, + provisioned: true, + profile: "micro", + }, + postgrest: versionedService("postgrest", options), + auth: versionedService("auth", options), + edgeRuntime: versionedService("edge-runtime", options), + realtime: versionedService("realtime", options), + storage: versionedService("storage", options), + imgproxy: versionedService("imgproxy", options), + mailpit: versionedService("mailpit", options), + pgmeta: versionedService("pgmeta", options), + studio: versionedService("studio", options), + analytics: versionedService("analytics", options), + vector: versionedService("vector", options), + pooler: versionedService("pooler", options), + functions: options.functions ?? false, + }; +} diff --git a/packages/stack/src/provisionedStack.unit.test.ts b/packages/stack/src/provisionedStack.unit.test.ts new file mode 100644 index 0000000000..e47f301217 --- /dev/null +++ b/packages/stack/src/provisionedStack.unit.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { provisionedStackConfig } from "./provisionedStack.ts"; + +const baseOptions = { + dataDir: "/pods/a/data", + postgresPassword: "secret", + versions: { postgres: "17.6.1.143" }, +} as const; + +describe("provisionedStackConfig", () => { + it("owns the complete postgres-only micro runtime configuration", () => { + expect(provisionedStackConfig(baseOptions)).toEqual({ + mode: "auto", + stackRoot: undefined, + startServices: undefined, + projectDir: undefined, + jwtSecret: undefined, + publishableKey: undefined, + secretKey: undefined, + services: undefined, + postgres: { + dataDir: "/pods/a/data", + version: "17.6.1.143", + password: "secret", + provisioned: true, + profile: "micro", + }, + postgrest: undefined, + auth: undefined, + edgeRuntime: undefined, + realtime: undefined, + storage: undefined, + imgproxy: undefined, + mailpit: undefined, + pgmeta: undefined, + studio: undefined, + analytics: undefined, + vector: undefined, + pooler: undefined, + functions: false, + }); + }); + + it("maps enabled service versions and runtime policy", () => { + const config = provisionedStackConfig({ + ...baseOptions, + stackRoot: "/pods/a/stack", + startServices: ["postgrest", "auth"], + services: ["postgrest", "auth"], + versions: { + postgres: "17.6.1.143", + postgrest: "14.14", + auth: "2.192.0", + }, + }); + + expect(config.stackRoot).toBe("/pods/a/stack"); + expect(config.startServices).toEqual(["postgrest", "auth"]); + expect(config.postgrest).toEqual({ version: "14.14" }); + expect(config.auth).toEqual({ version: "2.192.0" }); + }); + + it("rejects an enabled service without a pinned version", () => { + expect(() => provisionedStackConfig({ ...baseOptions, services: ["postgrest"] })).toThrow( + "versions.postgrest is required", + ); + }); + + it("rejects incomplete service dependency sets", () => { + expect(() => + provisionedStackConfig({ + ...baseOptions, + services: ["studio"], + versions: { postgres: "17.6.1.143", studio: "2026.07.07" }, + }), + ).toThrow("studio requires pgmeta"); + }); +}); diff --git a/packages/stack/src/serviceDependencies.ts b/packages/stack/src/serviceDependencies.ts new file mode 100644 index 0000000000..026d50c67a --- /dev/null +++ b/packages/stack/src/serviceDependencies.ts @@ -0,0 +1,19 @@ +import type { ServiceName } from "./versions.ts"; + +export const validateEnabledServiceDependencies = ( + enabledServices: ReadonlySet, +): string | undefined => { + if (enabledServices.has("imgproxy") && !enabledServices.has("storage")) { + return "imgproxy requires storage to be enabled"; + } + + if (enabledServices.has("vector") && !enabledServices.has("analytics")) { + return "vector requires analytics to be enabled"; + } + + if (enabledServices.has("studio") && !enabledServices.has("pgmeta")) { + return "studio requires pgmeta to be enabled"; + } + + return undefined; +}; diff --git a/packages/stack/src/services/analytics.ts b/packages/stack/src/services/analytics.ts index 78c62f895e..383a4817f0 100644 --- a/packages/stack/src/services/analytics.ts +++ b/packages/stack/src/services/analytics.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { postgresConnectionUrl } from "../postgresCredentials.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; interface DockerAnalyticsOptions { @@ -9,6 +10,7 @@ interface DockerAnalyticsOptions { readonly nodeHost: string; readonly dbHost: string; readonly dbPort: number; + readonly dbPassword: string; readonly apiKey: string; readonly backend: "postgres" | "bigquery"; readonly networkArgs: ReadonlyArray; @@ -48,7 +50,7 @@ export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): Servic DB_PORT: String(opts.dbPort), DB_SCHEMA: "_analytics", DB_USERNAME: "postgres", - DB_PASSWORD: "postgres", + DB_PASSWORD: opts.dbPassword, LOGFLARE_MIN_CLUSTER_SIZE: "1", LOGFLARE_SINGLE_TENANT: "true", LOGFLARE_SUPABASE_MODE: "true", @@ -60,7 +62,13 @@ export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): Servic }; if (opts.backend === "postgres") { - env.POSTGRES_BACKEND_URL = `postgresql://postgres:postgres@${opts.dbHost}:${opts.dbPort}/_supabase`; + env.POSTGRES_BACKEND_URL = postgresConnectionUrl({ + user: "postgres", + password: opts.dbPassword, + host: opts.dbHost, + port: opts.dbPort, + database: "_supabase", + }); env.POSTGRES_BACKEND_SCHEMA = "_analytics"; } else { env.GOOGLE_DATASET_ID_APPEND = "_prod"; diff --git a/packages/stack/src/services/auth.ts b/packages/stack/src/services/auth.ts index b3e0360c83..fa4f5991c0 100644 --- a/packages/stack/src/services/auth.ts +++ b/packages/stack/src/services/auth.ts @@ -1,8 +1,10 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { postgresConnectionUrl } from "../postgresCredentials.ts"; import { dockerServiceCleanup, dockerServiceOrphanCleanup } from "./docker-cleanup.ts"; interface AuthServiceOptions { readonly dbPort: number; + readonly dbPassword: string; readonly authPort: number; readonly siteUrl: string; readonly jwtSecret: string; @@ -30,7 +32,13 @@ interface DockerAuthOptions extends AuthServiceOptions { } const authEnv = (opts: AuthServiceOptions, dbHost = "127.0.0.1"): Record => ({ - GOTRUE_DB_DATABASE_URL: `postgresql://supabase_auth_admin:postgres@${dbHost}:${opts.dbPort}/postgres`, + GOTRUE_DB_DATABASE_URL: postgresConnectionUrl({ + user: "supabase_auth_admin", + password: opts.dbPassword, + host: dbHost, + port: opts.dbPort, + database: "postgres", + }), GOTRUE_DB_DRIVER: "postgres", GOTRUE_SITE_URL: opts.siteUrl, GOTRUE_JWT_SECRET: opts.jwtSecret, @@ -82,13 +90,17 @@ export const makeAuthServiceNative = (opts: NativeAuthOptions): ServiceDef => ({ export const makeAuthServiceDocker = (opts: DockerAuthOptions): ServiceDef => { const env = authEnv(opts, opts.dbHost); - const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]); + // Name-only `-e KEY`: docker reads values from the docker CLI's environment + // (the `env` below), keeping the DB URL's password and the JWT secret out of + // the `docker run` argv visible in process listings. + const envArgs = Object.keys(env).flatMap((k) => ["-e", k]); const containerName = `supabase-auth-${opts.apiPort}`; return { name: "auth", command: "docker", args: ["run", "--rm", "--name", containerName, ...opts.networkArgs, ...envArgs, opts.image], + env, dependencies: opts.dependencies, healthCheck: authHealthCheck(opts.authPort), cleanup: dockerServiceCleanup(containerName), diff --git a/packages/stack/src/services/pgmeta.ts b/packages/stack/src/services/pgmeta.ts index 38c4fd282e..40c961eb8a 100644 --- a/packages/stack/src/services/pgmeta.ts +++ b/packages/stack/src/services/pgmeta.ts @@ -7,6 +7,7 @@ interface DockerPgmetaOptions { readonly port: number; readonly dbHost: string; readonly dbPort: number; + readonly dbPassword: string; readonly networkArgs: ReadonlyArray; readonly dependencies: ReadonlyArray; } @@ -36,7 +37,7 @@ export const makePgmetaServiceDocker = (opts: DockerPgmetaOptions): ServiceDef = PG_META_DB_NAME: "postgres", PG_META_DB_USER: "postgres", PG_META_DB_PORT: String(opts.dbPort), - PG_META_DB_PASSWORD: "postgres", + PG_META_DB_PASSWORD: opts.dbPassword, }, dependsOn: opts.dependencies, healthCheck: pgmetaHealthCheck(opts.port), diff --git a/packages/stack/src/services/pooler.ts b/packages/stack/src/services/pooler.ts index be75a7397b..4645341890 100644 --- a/packages/stack/src/services/pooler.ts +++ b/packages/stack/src/services/pooler.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { postgresConnectionUrl } from "../postgresCredentials.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; type PoolMode = "transaction" | "session"; @@ -9,6 +10,7 @@ interface DockerPoolerOptions { readonly hostAdminPort: number; readonly dbHost: string; readonly dbPort: number; + readonly dbPassword: string; readonly poolMode: PoolMode; readonly defaultPoolSize: number; readonly maxClientConn: number; @@ -33,6 +35,9 @@ const poolerHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ failureThreshold: 60, }); +// The password is embedded base64-encoded: this script is evaluated as Elixir +// code, where a double-quoted string still performs #{} interpolation, so a +// JSON.stringify'd literal would let password bytes execute as Elixir. const tenantScript = ( opts: DockerPoolerOptions, ) => `{:ok, _} = Application.ensure_all_started(:supavisor) @@ -54,15 +59,16 @@ params = %{ "default_parameter_status" => %{"server_version" => version}, "users" => [%{ "db_user" => "pgbouncer", - "db_password" => "postgres", + "db_password" => Base.decode64!("${Buffer.from(opts.dbPassword, "utf8").toString("base64")}"), "mode_type" => "${opts.poolMode}", "pool_size" => ${opts.defaultPoolSize}, "is_manager" => true }] } -if !Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do - {:ok, _} = Supavisor.Tenants.create_tenant(params) +case Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do + nil -> {:ok, _} = Supavisor.Tenants.create_tenant(params) + tenant -> {:ok, _} = Supavisor.Tenants.update_tenant(tenant, params) end`; export const makePoolerServiceDocker = (opts: DockerPoolerOptions): ServiceDef => @@ -76,7 +82,14 @@ export const makePoolerServiceDocker = (opts: DockerPoolerOptions): ServiceDef = PORT: String(poolerContainerPorts.admin), PROXY_PORT_SESSION: String(poolerContainerPorts.session), PROXY_PORT_TRANSACTION: String(poolerContainerPorts.transaction), - DATABASE_URL: `ecto://postgres:postgres@${opts.dbHost}:${opts.dbPort}/_supabase`, + DATABASE_URL: postgresConnectionUrl({ + scheme: "ecto", + user: "postgres", + password: opts.dbPassword, + host: opts.dbHost, + port: opts.dbPort, + database: "_supabase", + }), CLUSTER_POSTGRES: "true", SECRET_KEY_BASE: opts.secretKeyBase, VAULT_ENC_KEY: opts.encryptionKey, @@ -86,11 +99,12 @@ export const makePoolerServiceDocker = (opts: DockerPoolerOptions): ServiceDef = RUN_JANITOR: "true", ERL_AFLAGS: "-proto_dist inet_tcp", RLIMIT_NOFILE: "", + SUPAVISOR_TENANT_SCRIPT: tenantScript(opts), }, cmd: [ "/bin/sh", "-c", - `/app/bin/migrate && /app/bin/supavisor eval '${tenantScript(opts)}' && /app/bin/server`, + `/app/bin/migrate && /app/bin/supavisor eval "$SUPAVISOR_TENANT_SCRIPT" && /app/bin/server`, ], dependsOn: opts.dependencies, healthCheck: poolerHealthCheck(opts.hostAdminPort), diff --git a/packages/stack/src/services/postgres-init.ts b/packages/stack/src/services/postgres-init.ts index 694951230d..081b2671e3 100644 --- a/packages/stack/src/services/postgres-init.ts +++ b/packages/stack/src/services/postgres-init.ts @@ -3,6 +3,7 @@ import type { ServiceDef } from "@supabase/process-compose"; interface PostgresInitOptions { readonly postgresDir: string; readonly dbPort: number; + readonly dbPassword: string; /** * When false, append the SQL that Studio runs at cloud project creation to revoke the default * Data API privileges on the `public` schema so newly-created entities require explicit GRANTs. @@ -25,6 +26,12 @@ alter default privileges for role postgres in schema public revoke execute on functions from anon, authenticated, service_role; `.trim(); +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +const DEFAULT_POSTGRES_PASSWORD = "postgres"; + export const makePostgresInitService = (opts: PostgresInitOptions): ServiceDef => { const pgBinDir = `${opts.postgresDir}/bin`; const pgLibDir = `${opts.postgresDir}/lib`; @@ -32,6 +39,11 @@ export const makePostgresInitService = (opts: PostgresInitOptions): ServiceDef = const psql = `${pgBinDir}/psql -h 127.0.0.1 -p ${opts.dbPort}`; const psqlOpts = `-v ON_ERROR_STOP=1 --no-password --no-psqlrc`; + // The pgpass psql variable is set inside each SQL heredoc via a psql + // backtick command reading the exported env var, NOT via `-v pgpass=...`: + // a -v flag would expand the password into psql's argv, which any local + // user can read through ps / /proc//cmdline while psql runs. + const setPgpass = `\\set pgpass \`printf '%s' "$TARGET_PGPASSWORD"\``; const revokeStep = opts.autoExposeNewTables ? "" @@ -48,24 +60,52 @@ EOSQL // postgres-init time from ~5s to ~1s. const script = ` export PATH="${pgBinDir}:$PATH" -export PGPASSWORD=postgres +# The target password arrives via the PGPASSWORD env var (see env below), never +# interpolated into this script: the script is a "bash -c" argv entry, which any +# local user can read via ps / /proc//cmdline. +export TARGET_PGPASSWORD="$PGPASSWORD" +DEFAULT_PGPASSWORD=${shellQuote(DEFAULT_POSTGRES_PASSWORD)} db="${migrationsDir}" +is_initialized() { + ${psql} -U supabase_admin -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='authenticator'" 2>/dev/null | grep -q 1 +} + # 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 +initialized=false +if is_initialized; then + initialized=true +elif [ "$TARGET_PGPASSWORD" != "$DEFAULT_PGPASSWORD" ]; then + export PGPASSWORD="$DEFAULT_PGPASSWORD" + if is_initialized; then + initialized=true + else + export PGPASSWORD="$TARGET_PGPASSWORD" + fi +fi + +if [ "$initialized" = "true" ]; then echo "Database already initialized, updating passwords..." else + # Not initialized as far as we can tell — but distinguish "fresh data dir" + # (supabase_admin authenticates with the target password, set by initdb) + # from "initialized with some OTHER password" (neither the target nor the + # default authenticates). Running the migration path in the latter case + # would just cascade confusing psql auth failures. + if ! ${psql} ${psqlOpts} -U supabase_admin -d postgres -tAc "SELECT 1" >/dev/null 2>&1; then + echo "postgres data dir rejects both POSTGRES_PASSWORD and the default password;" >&2 + echo "it was initialized with a different password. Set POSTGRES_PASSWORD to the" >&2 + echo "password the data dir was created with, or re-create the data dir." >&2 + exit 1 + fi echo "Running Supabase migrations..." # Create postgres role if missing (as supabase_admin) ${psql} ${psqlOpts} -U supabase_admin -d postgres <<'EOSQL' -do $$ -begin - if not exists (select from pg_roles where rolname = 'postgres') then - create role postgres superuser login password 'postgres'; - alter database postgres owner to postgres; - end if; -end $$ +${setPgpass} +SELECT format('CREATE ROLE postgres SUPERUSER LOGIN PASSWORD %L', :'pgpass') +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'postgres')\\gexec +ALTER DATABASE postgres OWNER TO postgres; EOSQL # Run all init-scripts in a single psql session (as postgres) @@ -78,7 +118,10 @@ EOSQL fi # Set supabase_admin password (as postgres) - ${psql} ${psqlOpts} -U postgres -d postgres -c "ALTER USER supabase_admin WITH PASSWORD 'postgres'" + ${psql} ${psqlOpts} -U postgres -d postgres <<'EOSQL' +${setPgpass} +SELECT format('ALTER ROLE supabase_admin WITH PASSWORD %L', :'pgpass')\\gexec +EOSQL # Run all migrations in a single psql session (as supabase_admin) migrate_flags="" @@ -93,6 +136,26 @@ EOSQL ${psql} ${psqlOpts} -U supabase_admin -d postgres -c 'SELECT extensions.pg_stat_statements_reset(); SELECT pg_stat_reset();' || true ${revokeStep}fi +# Always update role passwords (idempotent) +${psql} ${psqlOpts} -U supabase_admin -d postgres <<'EOSQL' +${setPgpass} +SELECT format('ALTER ROLE %I WITH PASSWORD %L', rolname, :'pgpass') +FROM pg_roles +WHERE rolname = ANY (ARRAY[ + 'supabase_admin', + 'authenticator', + 'supabase_auth_admin', + 'supabase_storage_admin', + 'supabase_functions_admin', + 'supabase_replication_admin', + 'supabase_read_only_user', + 'pgbouncer', + 'postgres' +])\\gexec +EOSQL + +export PGPASSWORD="$TARGET_PGPASSWORD" + # Backfill schemas/databases used by docker-backed auxiliary services. ${psql} ${psqlOpts} -U postgres -d postgres <<'EOSQL' CREATE SCHEMA IF NOT EXISTS _realtime; @@ -109,22 +172,6 @@ ALTER SCHEMA _analytics OWNER TO postgres; CREATE SCHEMA IF NOT EXISTS _supavisor; ALTER SCHEMA _supavisor OWNER TO postgres; EOSQL - -# Always update role passwords (idempotent) -${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']; - r text; -BEGIN - FOREACH r IN ARRAY roles LOOP - IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r) THEN - EXECUTE format('ALTER ROLE %I WITH PASSWORD ''postgres''', r); - END IF; - END LOOP; -END -\\$\\$; -" `; return { @@ -134,7 +181,7 @@ END env: { DYLD_LIBRARY_PATH: pgLibDir, LD_LIBRARY_PATH: pgLibDir, - PGPASSWORD: "postgres", + PGPASSWORD: opts.dbPassword, }, dependencies: [{ service: "postgres", condition: "healthy" }], supervision: {}, diff --git a/packages/stack/src/services/postgres.ts b/packages/stack/src/services/postgres.ts index bb3b5953f7..34f744a138 100644 --- a/packages/stack/src/services/postgres.ts +++ b/packages/stack/src/services/postgres.ts @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import type { ServiceDef } from "@supabase/process-compose"; import { dockerServiceCleanup, @@ -9,6 +9,7 @@ import { interface PostgresServiceOptions { readonly dataDir: string; readonly port: number; + readonly password: string; readonly cleanupDataDirOnExit?: boolean; } @@ -16,6 +17,12 @@ interface NativePostgresOptions extends PostgresServiceOptions { readonly binPath: string; /** When true, patches postgres to listen on all interfaces so Docker containers can connect. */ readonly dockerAccessible?: boolean; + /** + * "micro": settings live in micro.conf/pod.conf inside PGDATA, so no `-c` runtime args are + * passed (command-line `-c` would override users' `ALTER SYSTEM` changes on every boot). + * Absent or "default": current `-c` args are passed unchanged. + */ + readonly profile?: "default" | "micro"; } interface DockerPostgresOptions extends PostgresServiceOptions { @@ -29,14 +36,14 @@ interface DockerPostgresOptions extends PostgresServiceOptions { const postgresEnv = (opts: NativePostgresOptions): Record => ({ PGDATA: opts.dataDir, - POSTGRES_PASSWORD: "postgres", + POSTGRES_PASSWORD: opts.password, DYLD_LIBRARY_PATH: `${opts.binPath}/lib`, LD_LIBRARY_PATH: `${opts.binPath}/lib`, TZDIR: "/var/db/timezone/zoneinfo", }); const postgresDockerEnv = (opts: DockerPostgresOptions): Record => ({ - POSTGRES_PASSWORD: "postgres", + POSTGRES_PASSWORD: opts.password, JWT_SECRET: opts.jwtSecret, JWT_EXP: String(opts.jwtExpiry), }); @@ -50,12 +57,33 @@ const NATIVE_POSTGRES_RUNTIME_ARGS = [ "max_replication_slots=5", ] as const; +const ACTIVE_MICRO_INCLUDE_RE = /^\s*include_if_exists = 'micro\.conf'/m; + +/** + * On the "micro" profile, these settings live in micro.conf/pod.conf inside PGDATA instead, + * so no `-c` runtime args are emitted; passing them on the command line would take precedence + * over the conf files and override users' `ALTER SYSTEM` changes on every restart. + */ +const runtimeArgsForProfile = ( + profile: "default" | "micro" | undefined, + dataDir: string, +): readonly string[] => { + if (profile !== "micro") return NATIVE_POSTGRES_RUNTIME_ARGS; + try { + return ACTIVE_MICRO_INCLUDE_RE.test(readFileSync(`${dataDir}/postgresql.conf`, "utf8")) + ? [] + : NATIVE_POSTGRES_RUNTIME_ARGS; + } catch { + return 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"\` +const DOCKER_POSTGRES_SCHEMA_SQL = `\\set pgpass \`printf '%s' "$PGPASSWORD"\` +\\set jwt_secret \`printf '%s' "$JWT_SECRET"\` +\\set jwt_exp \`printf '%s' "$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'; @@ -64,6 +92,7 @@ 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'; +ALTER USER pgbouncer WITH PASSWORD :'pgpass'; create schema if not exists _realtime; alter schema _realtime owner to postgres; SELECT 'CREATE DATABASE _supabase WITH OWNER postgres' @@ -141,7 +170,7 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => initScript, "-p", String(opts.port), - ...NATIVE_POSTGRES_RUNTIME_ARGS, + ...runtimeArgsForProfile(opts.profile, opts.dataDir), "-c", "listen_addresses=*", "-c", @@ -163,7 +192,12 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => return { name: "postgres", command: "bash", - args: [initScript, "-p", String(opts.port), ...NATIVE_POSTGRES_RUNTIME_ARGS], + args: [ + initScript, + "-p", + String(opts.port), + ...runtimeArgsForProfile(opts.profile, opts.dataDir), + ], env: postgresEnv(opts), healthCheck: postgresHealthCheck(opts.binPath, opts.port), shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, @@ -174,7 +208,10 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => export const makePostgresServiceDocker = (opts: DockerPostgresOptions): ServiceDef => { const env = postgresDockerEnv(opts); - const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]); + // Name-only `-e KEY`: docker reads the values from the docker CLI's own + // environment (the `env` on the ServiceDef below), keeping the password and + // JWT secret out of the `docker run` argv visible in process listings. + const envArgs = Object.keys(env).flatMap((k) => ["-e", k]); const containerName = `supabase-postgres-${opts.apiPort}`; const dockerArgs = [ "run", @@ -195,6 +232,7 @@ export const makePostgresServiceDocker = (opts: DockerPostgresOptions): ServiceD name: "postgres", command: "docker", args: dockerArgs, + env, healthCheck: postgresDockerHealthCheck(containerName, opts.port), shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, cleanup: dockerServiceCleanup(containerName), diff --git a/packages/stack/src/services/postgrest.ts b/packages/stack/src/services/postgrest.ts index fffd349457..1adf164b1d 100644 --- a/packages/stack/src/services/postgrest.ts +++ b/packages/stack/src/services/postgrest.ts @@ -1,8 +1,10 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { postgresConnectionUrl } from "../postgresCredentials.ts"; import { dockerServiceCleanup, dockerServiceOrphanCleanup } from "./docker-cleanup.ts"; interface PostgrestServiceOptions { readonly dbPort: number; + readonly dbPassword: string; readonly port: number; readonly schemas: ReadonlyArray; readonly extraSearchPath: ReadonlyArray; @@ -26,7 +28,13 @@ const postgrestEnv = ( opts: PostgrestServiceOptions, dbHost = "127.0.0.1", ): Record => ({ - PGRST_DB_URI: `postgresql://authenticator:postgres@${dbHost}:${opts.dbPort}/postgres`, + PGRST_DB_URI: postgresConnectionUrl({ + user: "authenticator", + password: opts.dbPassword, + host: dbHost, + port: opts.dbPort, + database: "postgres", + }), PGRST_DB_SCHEMAS: opts.schemas.join(","), PGRST_DB_EXTRA_SEARCH_PATH: opts.extraSearchPath.join(","), PGRST_DB_ANON_ROLE: "anon", @@ -64,13 +72,17 @@ export const makePostgrestServiceDocker = (opts: DockerPostgrestOptions): Servic ...postgrestEnv(opts, opts.dbHost), PGRST_ADMIN_SERVER_PORT: String(opts.adminPort), }; - const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]); + // Name-only `-e KEY`: docker reads values from the docker CLI's environment + // (the `env` below), keeping the DB URI's password and the JWT secret out of + // the `docker run` argv visible in process listings. + const envArgs = Object.keys(env).flatMap((k) => ["-e", k]); const containerName = `supabase-postgrest-${opts.apiPort}`; return { name: "postgrest", command: "docker", args: ["run", "--rm", "--name", containerName, ...opts.networkArgs, ...envArgs, opts.image], + env, dependencies: postgrestDependencies, healthCheck: postgrestHealthCheck(opts.port), cleanup: dockerServiceCleanup(containerName), diff --git a/packages/stack/src/services/realtime.ts b/packages/stack/src/services/realtime.ts index 4f4c209777..1383f8249f 100644 --- a/packages/stack/src/services/realtime.ts +++ b/packages/stack/src/services/realtime.ts @@ -7,6 +7,7 @@ interface DockerRealtimeOptions { readonly apiPort: number; readonly dbHost: string; readonly dbPort: number; + readonly dbPassword: string; readonly jwtSecret: string; readonly jwtJwks: string; readonly tenantId: string; @@ -47,7 +48,7 @@ export const makeRealtimeServiceDocker = (opts: DockerRealtimeOptions): ServiceD DB_HOST: opts.dbHost, DB_PORT: String(opts.dbPort), DB_USER: "postgres", - DB_PASSWORD: "postgres", + DB_PASSWORD: opts.dbPassword, DB_NAME: "postgres", DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", DB_ENC_KEY: opts.encryptionKey, diff --git a/packages/stack/src/services/service-utils.ts b/packages/stack/src/services/service-utils.ts index eabb31cd5e..524f67dd63 100644 --- a/packages/stack/src/services/service-utils.ts +++ b/packages/stack/src/services/service-utils.ts @@ -23,8 +23,12 @@ interface DockerRunServiceOptions { readonly orphanCleanup?: ReadonlyArray; } +// Name-only `-e KEY` flags: docker reads each value from the docker CLI's own +// environment (ServiceDef.env, merged over the parent env at spawn), so +// secrets like DB passwords and JWT keys never appear in the `docker run` +// argv, which any local user can read via ps / /proc//cmdline. const envArgs = (env: Record): ReadonlyArray => - Object.entries(env).flatMap(([key, value]) => ["-e", `${key}=${value}`]); + Object.keys(env).flatMap((key) => ["-e", key]); export const hostHttpHealthCheck = ( port: number, @@ -74,6 +78,7 @@ export const dockerRunService = (opts: DockerRunServiceOptions): ServiceDef => { name: opts.name, command: "docker", args: dockerArgs, + env: opts.env, dependencies: opts.dependsOn, healthCheck: opts.healthCheck, shutdown: opts.shutdown, diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index 2db96bb9af..ccee912497 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -21,6 +21,7 @@ import { DEFAULT_VERSIONS, dockerImageForService } from "../versions.ts"; const JWT_SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"; const DB_PORT = 54322; +const DB_PASSWORD = "postgres"; const API_PORT = 54321; const POSTGRES_BIN_PATH = `/cache/postgres/${DEFAULT_VERSIONS.postgres}/darwin-arm64`; const POSTGREST_BIN_PATH = `/cache/postgrest/${DEFAULT_VERSIONS.postgrest}/macos-aarch64`; @@ -34,6 +35,7 @@ describe("makePostgresService", () => { binPath: POSTGRES_BIN_PATH, dataDir: "/tmp/supabase/data", port: DB_PORT, + password: DB_PASSWORD, }); expect(def.name).toBe("postgres"); @@ -65,6 +67,44 @@ describe("makePostgresService", () => { expect(def.restart).toBe("unless-stopped"); expect(def.supervision).toBeDefined(); }); + + it("keeps runtime replication args for fresh micro-profile data dirs", () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "stack-postgres-service-")); + const dataDir = path.join(tempDir, "data"); + const def = makePostgresService({ + binPath: POSTGRES_BIN_PATH, + dataDir, + port: DB_PORT, + password: DB_PASSWORD, + profile: "micro", + }); + + try { + expect(def.args).toContain("wal_level=logical"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("omits runtime replication args when the micro-profile overlay is installed", () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "stack-postgres-service-")); + const dataDir = path.join(tempDir, "data"); + mkdirSync(dataDir, { recursive: true }); + writeFileSync(path.join(dataDir, "postgresql.conf"), "include_if_exists = 'micro.conf'\n"); + const def = makePostgresService({ + binPath: POSTGRES_BIN_PATH, + dataDir, + port: DB_PORT, + password: DB_PASSWORD, + profile: "micro", + }); + + try { + expect(def.args).not.toContain("wal_level=logical"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); }); describe("analyticsDockerRuntimeNetwork", () => { @@ -92,6 +132,7 @@ describe("makeStudioServiceDocker", () => { apiUrl: "http://host.docker.internal:54321", publicApiUrl: "http://127.0.0.1:54321", pgmetaUrl: "http://host.docker.internal:54322", + dbPassword: DB_PASSWORD, publishableKey: "sb_publishable_test", secretKey: "sb_secret_test", s3ProtocolAccessKeyId: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, @@ -105,14 +146,15 @@ describe("makeStudioServiceDocker", () => { dependencies: [{ service: "pgmeta", condition: "healthy" }], }); - expect(def.args).toContain("SUPABASE_ANON_KEY=sb_publishable_test"); - expect(def.args).toContain("SUPABASE_SERVICE_KEY=sb_secret_test"); - expect(def.args).toContain("SUPABASE_PUBLISHABLE_KEY=sb_publishable_test"); - expect(def.args).toContain("SUPABASE_SECRET_KEY=sb_secret_test"); - expect(def.args).toContain(`S3_PROTOCOL_ACCESS_KEY_ID=${LOCAL_S3_PROTOCOL_ACCESS_KEY_ID}`); - expect(def.args).toContain( - `S3_PROTOCOL_ACCESS_KEY_SECRET=${LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET}`, - ); + // Values travel via the docker CLI's environment; argv only names the keys. + expect(def.env?.SUPABASE_ANON_KEY).toBe("sb_publishable_test"); + expect(def.env?.SUPABASE_SERVICE_KEY).toBe("sb_secret_test"); + expect(def.env?.SUPABASE_PUBLISHABLE_KEY).toBe("sb_publishable_test"); + expect(def.env?.SUPABASE_SECRET_KEY).toBe("sb_secret_test"); + expect(def.env?.S3_PROTOCOL_ACCESS_KEY_ID).toBe(LOCAL_S3_PROTOCOL_ACCESS_KEY_ID); + expect(def.env?.S3_PROTOCOL_ACCESS_KEY_SECRET).toBe(LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET); + expect(def.args).toContain("SUPABASE_SECRET_KEY"); + expect(def.args?.join(" ")).not.toContain("sb_secret_test"); }); }); @@ -123,6 +165,7 @@ describe("makePostgresService (dockerAccessible)", () => { binPath: POSTGRES_BIN_PATH, dataDir: path.join(tempDir, "data"), port: DB_PORT, + password: DB_PASSWORD, dockerAccessible: true, cleanupDataDirOnExit: true, }); @@ -166,6 +209,7 @@ describe("makePostgresServiceDocker", () => { image: dockerImageForService("postgres", DEFAULT_VERSIONS.postgres), dataDir: "/tmp/supabase/data", port: DB_PORT, + password: DB_PASSWORD, networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", `${DB_PORT}:${DB_PORT}`], jwtSecret: "test-jwt-secret-with-at-least-32-characters", jwtExpiry: 3600, @@ -202,6 +246,11 @@ describe("makePostgresServiceDocker", () => { expect(def.supervision).toEqual({ orphanCleanup: [{ _tag: "DockerRemove", containerName: `supabase-postgres-${API_PORT}` }], }); + // Secrets travel via the docker CLI's environment; argv only names the keys. + expect(def.env?.POSTGRES_PASSWORD).toBe(DB_PASSWORD); + expect(def.args).toContain("POSTGRES_PASSWORD"); + expect(def.args?.join(" ")).not.toContain(`POSTGRES_PASSWORD=${DB_PASSWORD}`); + expect(def.args?.join(" ")).not.toContain("test-jwt-secret-with-at-least-32-characters"); }); it("bootstraps auxiliary databases and schemas used by docker-backed services", () => { @@ -209,6 +258,7 @@ describe("makePostgresServiceDocker", () => { image: dockerImageForService("postgres", DEFAULT_VERSIONS.postgres), dataDir: "/tmp/supabase/data", port: DB_PORT, + password: DB_PASSWORD, networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", `${DB_PORT}:${DB_PORT}`], jwtSecret: "test-jwt-secret-with-at-least-32-characters", jwtExpiry: 3600, @@ -223,6 +273,7 @@ describe("makePostgresServiceDocker", () => { expect(script).toContain("\\connect _supabase"); expect(script).toContain("create schema if not exists _analytics;"); expect(script).toContain("create schema if not exists _supavisor;"); + expect(script).toContain("ALTER USER pgbouncer WITH PASSWORD :'pgpass';"); }); }); @@ -231,6 +282,7 @@ describe("makePostgrestService", () => { const def = makePostgrestService({ binPath: POSTGREST_BIN_PATH, dbPort: DB_PORT, + dbPassword: DB_PASSWORD, port: API_PORT, schemas: ["public", "storage"], extraSearchPath: ["public", "extensions"], @@ -263,6 +315,7 @@ describe("makeAuthServiceNative", () => { const def = makeAuthServiceNative({ binPath: AUTH_BIN_PATH, dbPort: DB_PORT, + dbPassword: DB_PASSWORD, authPort: 9999, siteUrl: "http://localhost:3000", jwtSecret: JWT_SECRET, @@ -293,6 +346,7 @@ describe("makeAuthServiceDocker", () => { const def = makeAuthServiceDocker({ image: dockerImageForService("auth", DEFAULT_VERSIONS.auth), dbPort: DB_PORT, + dbPassword: DB_PASSWORD, authPort: 9999, siteUrl: "http://localhost:3000", jwtSecret: JWT_SECRET, @@ -408,6 +462,7 @@ describe("makePostgresInitService", () => { const def = makePostgresInitService({ postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, + dbPassword: DB_PASSWORD, autoExposeNewTables: true, }); @@ -426,6 +481,7 @@ describe("makePostgresInitService", () => { const def = makePostgresInitService({ postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, + dbPassword: DB_PASSWORD, autoExposeNewTables: true, }); const script = def.args?.[1] as string; @@ -436,6 +492,7 @@ describe("makePostgresInitService", () => { const def = makePostgresInitService({ postgresDir: "/cache/postgres/17/darwin-arm64", dbPort: DB_PORT, + dbPassword: DB_PASSWORD, autoExposeNewTables: true, }); const script = def.args?.[1] as string; @@ -443,10 +500,61 @@ describe("makePostgresInitService", () => { expect(script).toContain("already initialized"); }); + it("keeps the pooler database role password in sync", () => { + const def = makePostgresInitService({ + postgresDir: "/cache/postgres/17/darwin-arm64", + dbPort: DB_PORT, + dbPassword: DB_PASSWORD, + autoExposeNewTables: true, + }); + const script = def.args?.[1] as string; + expect(script).toContain("'pgbouncer'"); + }); + + it("keeps the supabase_admin database role password in sync", () => { + const def = makePostgresInitService({ + postgresDir: "/cache/postgres/17/darwin-arm64", + dbPort: DB_PORT, + dbPassword: DB_PASSWORD, + autoExposeNewTables: true, + }); + const script = def.args?.[1] as string; + expect(script).toContain("'supabase_admin'"); + }); + + it("separates the current auth password from the target role password", () => { + const def = makePostgresInitService({ + postgresDir: "/cache/postgres/17/darwin-arm64", + dbPort: DB_PORT, + dbPassword: "new-password", + autoExposeNewTables: true, + }); + const script = def.args?.[1] as string; + + // The target password must only travel via the process env, never inside + // the script itself (a bash -c argv entry is visible in process listings) + // and never through psql argv (-v pgpass=... would expand the secret into + // the psql child's cmdline). + expect(script).not.toContain("new-password"); + expect(def.env?.PGPASSWORD).toBe("new-password"); + expect(script).toContain('export TARGET_PGPASSWORD="$PGPASSWORD"'); + expect(script).toContain('export PGPASSWORD="$DEFAULT_PGPASSWORD"'); + expect(script).not.toContain("-v pgpass="); + expect(script).toContain("\\set pgpass `printf '%s' \"$TARGET_PGPASSWORD\"`"); + + const updateRoles = script.indexOf("# Always update role passwords"); + const useTargetPassword = script.indexOf('export PGPASSWORD="$TARGET_PGPASSWORD"', updateRoles); + const backfill = script.indexOf("# Backfill schemas/databases", updateRoles); + expect(updateRoles).toBeGreaterThan(-1); + expect(useTargetPassword).toBeGreaterThan(updateRoles); + expect(backfill).toBeGreaterThan(useTargetPassword); + }); + it("backfills auxiliary service schemas and internal databases", () => { const def = makePostgresInitService({ postgresDir: "/cache/postgres/17/darwin-arm64", dbPort: DB_PORT, + dbPassword: DB_PASSWORD, autoExposeNewTables: true, }); const script = def.args?.[1] as string; @@ -462,6 +570,7 @@ describe("makePostgresInitService", () => { const def = makePostgresInitService({ postgresDir: "/cache/postgres/17/darwin-arm64", dbPort: DB_PORT, + dbPassword: DB_PASSWORD, autoExposeNewTables: true, }); const script = def.args?.[1] as string; @@ -475,6 +584,7 @@ describe("makePostgresInitService", () => { const def = makePostgresInitService({ postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, + dbPassword: DB_PASSWORD, autoExposeNewTables: true, }); const script = def.args?.[1] as string; @@ -486,6 +596,7 @@ describe("makePostgresInitService", () => { const def = makePostgresInitService({ postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, + dbPassword: DB_PASSWORD, autoExposeNewTables: false, }); const script = def.args?.[1] as string; @@ -581,6 +692,7 @@ describe("docker-backed auxiliary services", () => { nodeHost: "0.0.0.0", dbHost: "127.0.0.1", dbPort: DB_PORT, + dbPassword: DB_PASSWORD, apiKey: "test-api-key", backend: "postgres", networkArgs: ["-p", "54328:4000"], @@ -596,10 +708,10 @@ describe("docker-backed auxiliary services", () => { scheme: "http", }); expect(def.healthCheck?.initialDelaySeconds).toBe(10); - expect(args).toContain("PORT=4000"); - expect(args).toContain("PHX_HTTP_PORT=4000"); + expect(def.env?.PORT).toBe("4000"); + expect(def.env?.PHX_HTTP_PORT).toBe("4000"); expect(args).toContain("54328:4000"); - expect(args).toContain("LOGFLARE_NODE_HOST=0.0.0.0"); + expect(def.env?.LOGFLARE_NODE_HOST).toBe("0.0.0.0"); }); it("keeps analytics on its container port when Linux uses bridge networking", () => { @@ -611,15 +723,16 @@ describe("docker-backed auxiliary services", () => { nodeHost: "0.0.0.0", dbHost: "host.docker.internal", dbPort: DB_PORT, + dbPassword: DB_PASSWORD, apiKey: "test-api-key", backend: "postgres", networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", "54328:4000"], dependencies: [{ service: "postgres", condition: "healthy" }], }); - expect(def.args).toContain("PORT=4000"); - expect(def.args).toContain("PHX_HTTP_PORT=4000"); - expect(def.args).toContain("LOGFLARE_NODE_HOST=0.0.0.0"); + expect(def.env?.PORT).toBe("4000"); + expect(def.env?.PHX_HTTP_PORT).toBe("4000"); + expect(def.env?.LOGFLARE_NODE_HOST).toBe("0.0.0.0"); expect(def.args).toContain("host.docker.internal:host-gateway"); expect(def.args).toContain("54328:4000"); }); @@ -631,6 +744,7 @@ describe("docker-backed auxiliary services", () => { hostAdminPort: 54329, dbHost: "127.0.0.1", dbPort: DB_PORT, + dbPassword: DB_PASSWORD, poolMode: "transaction", defaultPoolSize: 20, maxClientConn: 100, @@ -654,10 +768,25 @@ describe("docker-backed auxiliary services", () => { path: "/api/health", scheme: "http", }); - expect(def.args).toContain(`PORT=${poolerContainerPorts.admin}`); - expect(def.args).toContain(`PROXY_PORT_SESSION=${poolerContainerPorts.session}`); - expect(def.args).toContain(`PROXY_PORT_TRANSACTION=${poolerContainerPorts.transaction}`); + expect(def.env?.PORT).toBe(String(poolerContainerPorts.admin)); + expect(def.env?.PROXY_PORT_SESSION).toBe(String(poolerContainerPorts.session)); + expect(def.env?.PROXY_PORT_TRANSACTION).toBe(String(poolerContainerPorts.transaction)); expect(def.args).toContain(`54329:${poolerContainerPorts.admin}`); expect(def.args).toContain(`54330:${poolerContainerPorts.transaction}`); + const args = def.args ?? []; + const tenantScript = def.env?.SUPAVISOR_TENANT_SCRIPT; + expect(tenantScript).toBeDefined(); + expect(tenantScript).toContain("Supavisor.Tenants.create_tenant(params)"); + expect(tenantScript).toContain("Supavisor.Tenants.update_tenant(tenant, params)"); + // The password is embedded base64-encoded so no byte sequence (e.g. #{}) + // can be interpolated as Elixir code by `supavisor eval`. + expect(tenantScript).toContain( + `Base.decode64!("${Buffer.from(DB_PASSWORD, "utf8").toString("base64")}")`, + ); + // The script itself reaches docker via the CLI's environment; argv only + // names the variable, so the embedded credentials stay out of ps output. + expect(args).toContain("SUPAVISOR_TENANT_SCRIPT"); + expect(args.join(" ")).not.toContain("Supavisor.Tenants.create_tenant"); + expect(args.join(" ")).toContain('supavisor eval "$SUPAVISOR_TENANT_SCRIPT"'); }); }); diff --git a/packages/stack/src/services/storage.ts b/packages/stack/src/services/storage.ts index 056a27605e..060d3f00f0 100644 --- a/packages/stack/src/services/storage.ts +++ b/packages/stack/src/services/storage.ts @@ -1,4 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; +import { postgresConnectionUrl } from "../postgresCredentials.ts"; import { removePathOnOrphanCleanup } from "./docker-cleanup.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; @@ -8,6 +9,7 @@ interface DockerStorageOptions { readonly apiPort: number; readonly dbHost: string; readonly dbPort: number; + readonly dbPassword: string; readonly dataDir: string; readonly anonKey: string; readonly serviceKey: string; @@ -57,7 +59,13 @@ export const makeStorageServiceDocker = (opts: DockerStorageOptions): ServiceDef AUTH_JWT_SECRET: opts.jwtSecret, PGRST_JWT_SECRET: opts.jwtSecret, JWT_JWKS: opts.jwtJwks, - DATABASE_URL: `postgresql://supabase_storage_admin:postgres@${opts.dbHost}:${opts.dbPort}/postgres`, + DATABASE_URL: postgresConnectionUrl({ + user: "supabase_storage_admin", + password: opts.dbPassword, + host: opts.dbHost, + port: opts.dbPort, + database: "postgres", + }), FILE_SIZE_LIMIT: opts.fileSizeLimit, STORAGE_BACKEND: "file", FILE_STORAGE_BACKEND_PATH: STORAGE_DATA_DIR, diff --git a/packages/stack/src/services/studio.ts b/packages/stack/src/services/studio.ts index 7521fce3c7..d91853f277 100644 --- a/packages/stack/src/services/studio.ts +++ b/packages/stack/src/services/studio.ts @@ -8,6 +8,7 @@ interface DockerStudioOptions { readonly apiUrl: string; readonly publicApiUrl: string; readonly pgmetaUrl: string; + readonly dbPassword: string; readonly publishableKey: string; readonly secretKey: string; readonly s3ProtocolAccessKeyId: string; @@ -44,7 +45,7 @@ export const makeStudioServiceDocker = (opts: DockerStudioOptions): ServiceDef = PORT: String(opts.port), CURRENT_CLI_VERSION: "local", STUDIO_PG_META_URL: opts.pgmetaUrl, - POSTGRES_PASSWORD: "postgres", + POSTGRES_PASSWORD: opts.dbPassword, SUPABASE_URL: opts.apiUrl, SUPABASE_PUBLIC_URL: opts.publicApiUrl, AUTH_JWT_SECRET: opts.jwtSecret, diff --git a/packages/stack/src/versions.ts b/packages/stack/src/versions.ts index e9196cea68..aebd30501d 100644 --- a/packages/stack/src/versions.ts +++ b/packages/stack/src/versions.ts @@ -13,6 +13,9 @@ export type ServiceName = | "vector" | "pooler"; +/** Stack sidecars that callers may opt into; Postgres is always present. */ +export type StackServiceName = Exclude; + export const SERVICE_NAMES = [ "postgres", "postgrest", @@ -29,6 +32,10 @@ export const SERVICE_NAMES = [ "pooler", ] as const satisfies ReadonlyArray; +export const STACK_SERVICE_NAMES = SERVICE_NAMES.filter( + (service): service is StackServiceName => service !== "postgres", +); + export interface VersionManifest { readonly postgres: string; readonly postgrest: string; diff --git a/packages/stack/tests/createStack-docker.e2e.test.ts b/packages/stack/tests/createStack-docker.e2e.test.ts index 41eacc6e2b..321f13ed1f 100644 --- a/packages/stack/tests/createStack-docker.e2e.test.ts +++ b/packages/stack/tests/createStack-docker.e2e.test.ts @@ -36,13 +36,6 @@ dockerDescribe("createStack e2e (docker mode)", () => { postgres: { dataDir }, }); - try { - await stack.start(); - } catch (startError) { - await stack.dispose().catch(() => {}); - throw startError; - } - const dbPort = parseInt(new URL(stack.dbUrl).port); await setupTestTable(dbPort); diff --git a/packages/stack/tests/createStack.e2e.test.ts b/packages/stack/tests/createStack.e2e.test.ts index 9b734fa450..c1180cb3d5 100644 --- a/packages/stack/tests/createStack.e2e.test.ts +++ b/packages/stack/tests/createStack.e2e.test.ts @@ -26,13 +26,6 @@ describe("createStack e2e", () => { postgres: { dataDir }, }); - try { - await stack.start(); - } catch (startError) { - await stack.dispose().catch(() => {}); - throw startError; - } - const dbPort = parseInt(new URL(stack.dbUrl).port); await setupTestTable(dbPort); diff --git a/packages/stack/tests/fleetDensity.e2e.test.ts b/packages/stack/tests/fleetDensity.e2e.test.ts new file mode 100644 index 0000000000..89095be2bc --- /dev/null +++ b/packages/stack/tests/fleetDensity.e2e.test.ts @@ -0,0 +1,45 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createFleet } from "../src/fleet/index.ts"; + +const PG_VERSION = "17.6.1.143"; +const REGISTERED = Number(process.env.FLEET_E2E_PODS ?? 20); // 100+ locally, 20 in CI +const WARM = 3; + +describe.skipIf(!process.env.FLEET_PG_TESTS)("fleet density", () => { + it(`registers ${REGISTERED} pods, wakes ${WARM}, suspends cleanly`, async () => { + const root = await mkdtemp(join(tmpdir(), "fleet-density-")); + await using fleet = await createFleet({ root, idleMs: 60_000 }); + + // Registration is cheap: template built once, then CoW clones. + for (let i = 0; i < REGISTERED; i += 1) { + await fleet.createPod({ + id: `pod-${i}`, + versions: { postgres: PG_VERSION }, + services: [], + warmTemplate: false, + start: false, + }); + } + const all = await fleet.listPods(); + expect(all).toHaveLength(REGISTERED); + expect(all.every((p) => p.state === "suspended")).toBe(true); + + // Distinct external ports across the whole fleet. + const portSet = new Set(all.map((p) => new URL(p.dbUrl).port)); + expect(portSet.size).toBe(REGISTERED); + + // Wake a subset; the rest stay suspended (zero processes). + for (let i = 0; i < WARM; i += 1) await fleet.wake(`pod-${i}`); + const after = await fleet.listPods(); + expect(after.filter((p) => p.state === "warm")).toHaveLength(WARM); + expect(after.filter((p) => p.state === "suspended")).toHaveLength(REGISTERED - WARM); + + // Explicit suspend brings a pod back to zero. + await fleet.suspend("pod-0"); + const final = await fleet.listPods(); + expect(final.find((p) => p.id === "pod-0")?.state).toBe("suspended"); + }, 900_000); +}); diff --git a/packages/stack/tests/helpers/buildServices.ts b/packages/stack/tests/helpers/buildServices.ts new file mode 100644 index 0000000000..8cd5a0e54a --- /dev/null +++ b/packages/stack/tests/helpers/buildServices.ts @@ -0,0 +1,165 @@ +import type { ServiceDef } from "@supabase/process-compose"; +import { Deferred, Effect, Layer, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { defaultPublishableKey, defaultSecretKey, generateJwt } from "../../src/JwtGenerator.ts"; +import type { ResolvedStackConfig, StackConfig } from "../../src/StackBuilder.ts"; +import { + enabledServicesForConfig, + StackBuilder, + versionsForConfig, +} from "../../src/StackBuilder.ts"; +import type { StackPreparationInput } from "../../src/StackPreparation.ts"; +import { StackPreparation } from "../../src/StackPreparation.ts"; +import { DEFAULT_VERSIONS } from "../../src/versions.ts"; +import { mockBinaryResolver } from "./mocks.ts"; + +const encoder = new TextEncoder(); + +/** + * Mirrors the local helper of the same name in `src/StackBuilder.unit.test.ts` / + * `src/prefetch.unit.test.ts`. Not centralized in `mocks.ts` to avoid touching those + * pre-existing test files for this change; only the exit code matters here since the + * fixtures below never enable any docker-only service (no registry pulls happen). + */ +function mockSequenceSpawner( + results: ReadonlyArray<{ readonly exitCode: number; readonly stderr?: string[] }>, +) { + let index = 0; + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((_command) => + Effect.gen(function* () { + const result = results[index] ?? { exitCode: 0 }; + index += 1; + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(3000 + index), + stdout: Stream.empty, + stderr: Stream.fromIterable( + (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)), + ), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(true), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ); +} + +const testJwtSecret = "super-secret-jwt-token-with-at-least-32-characters"; + +const basePorts = { + apiPort: 3000, + dbPort: 5432, + authPort: 9999, + postgrestPort: 3001, + postgrestAdminPort: 3002, + edgeRuntimePort: 3003, + edgeRuntimeInspectorPort: 3004, + realtimePort: 3010, + storagePort: 3011, + imgproxyPort: 3012, + mailpitPort: 3013, + mailpitSmtpPort: 3014, + mailpitPop3Port: 3015, + pgmetaPort: 3016, + studioPort: 3017, + analyticsPort: 3018, + poolerPort: 3019, + poolerApiPort: 3020, +}; + +/** + * Minimal ResolvedStackConfig fixture with every optional service disabled. Mirrors + * `baseConfig` in `src/StackBuilder.unit.test.ts`; kept in sync manually since the two + * currently have no shared export. + */ +const baseConfig: ResolvedStackConfig = { + cacheRoot: "/tmp/supabase-cache", + stackRoot: "/tmp/supabase-stack", + runtimeRoot: "/tmp/supabase-runtime", + projectDir: "/tmp/supabase-project", + mode: "auto", + jwtSecret: testJwtSecret, + startServices: [], + ports: basePorts, + apiPort: 3000, + dbPort: 5432, + publishableKey: defaultPublishableKey, + secretKey: defaultSecretKey, + functions: false, + autoManagedPaths: [], + anonJwt: generateJwt(testJwtSecret, "anon"), + serviceRoleJwt: generateJwt(testJwtSecret, "service_role"), + postgres: { + port: 5432, + dataDir: "/tmp/pg-data", + version: DEFAULT_VERSIONS.postgres, + password: "postgres", + autoExposeNewTables: true, + }, + postgrest: false, + auth: false, + edgeRuntime: false, + realtime: false, + storage: false, + imgproxy: false, + mailpit: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, +}; + +/** + * Builds the ServiceDef list produced by `StackBuilder.build()` for a partial + * `StackConfig`, with every service other than postgres disabled by default and no real + * process spawning (BinaryResolver and ChildProcessSpawner are both mocked). Only the + * `postgres` sub-config and `mode` are honored from the provided partial config; this stays + * intentionally narrow because it currently only backs the `provisioned`/`profile` wiring tests. + */ +export async function buildServicesForTest( + partial: Pick & Pick, "mode">, +): Promise> { + const config: ResolvedStackConfig = { + ...baseConfig, + mode: partial.mode ?? baseConfig.mode, + postgres: { + ...baseConfig.postgres, + ...partial.postgres, + }, + }; + + const resolver = mockBinaryResolver(); + const layer = Layer.mergeAll( + StackBuilder.layer, + StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(mockSequenceSpawner([{ exitCode: 0 }])), + ), + ); + + const program = Effect.gen(function* () { + const builder = yield* StackBuilder; + const preparation = yield* StackPreparation; + const input: StackPreparationInput = { + mode: config.mode, + services: enabledServicesForConfig(config), + versions: versionsForConfig(config), + }; + const prepared = yield* preparation.prepare(input); + const { graph } = yield* builder.build(config, prepared); + return graph.startOrder; + }).pipe(Effect.provide(layer)); + + return Effect.runPromise(program as Effect.Effect, unknown>); +} diff --git a/packages/stack/tests/helpers/standalone-stack.ts b/packages/stack/tests/helpers/standalone-stack.ts index 454b5a82ff..caff803a38 100644 --- a/packages/stack/tests/helpers/standalone-stack.ts +++ b/packages/stack/tests/helpers/standalone-stack.ts @@ -2,7 +2,6 @@ import { createStack } from "../../src/node.ts"; const parentPid = readParentPid(process.argv.slice(2)); const stack = await createStack(); -await stack.start(); // Signal readiness to parent process console.log(JSON.stringify({ url: stack.url, dbUrl: stack.dbUrl }));