diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index d9e1aca763..5ce114e052 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, + enableExtension: () => 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..5069dcd13c 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 })), + enableExtension: (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..cb5d27aa4c --- /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-enable + +`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 `enable-extension `: 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` + new fleet daemon + +`@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 daemon = new thin host-level layer** 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. Hosts warm pods as in-process `StackHandle`s inside one Bun process. Programmatic TypeScript API (`fleet.create()`, `fleet.wake()`, `fleet.fork()`, `fleet.suspend()`); `supabase start/stop` become thin calls over it. + +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 `createStack` a pre-cloned data dir. (Per-boot init can never support `fork`; templates can.) +2. **Micro config profile:** the Postgres service gains the conf-overlay mechanism and an `enableExtension(name)` API implementing preload-on-enable. +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); preload-on-enable 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..67fc79ae70 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-micro-stacks-phase1-stack-and-fleet.md @@ -0,0 +1,2265 @@ +# Micro Stacks Phase 1 — `@supabase/stack` Changes + `@supabase/fleet` Daemon + +> **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: `enableExtension` 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/enableExtension.unit.test.ts` + +**Interfaces:** +- Consumes: `PRELOAD_REQUIRED_EXTENSIONS` (Task 2), `readPreloadLibraries`/`writePreloadLibraries` (Task 3), coordinator `restartService` (existing). +- Produces: + - Coordinator: `readonly enableExtension: (name: string) => Effect.Effect` + - `StackHandle.enableExtension(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/enableExtension.unit.test.ts +import { describe, expect, it } from "vitest"; +import { planEnableExtension } from "./enableExtension.ts"; + +describe("planEnableExtension", () => { + it("no-ops for extensions that do not need preload", () => { + expect(planEnableExtension("pgvector", [])).toEqual({ action: "none" }); + }); + it("no-ops when already preloaded", () => { + expect(planEnableExtension("pg_cron", ["pg_cron"])).toEqual({ action: "none" }); + }); + it("appends and restarts otherwise", () => { + expect(planEnableExtension("pg_cron", ["pg_net"])).toEqual({ + action: "restart", + libraries: ["pg_net", "pg_cron"], + }); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/stack && pnpm vitest run src/enableExtension.unit.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```typescript +// src/enableExtension.ts +import { PRELOAD_REQUIRED_EXTENSIONS } from "./micro.ts"; + +export type EnableExtensionPlan = + | { readonly action: "none" } + | { readonly action: "restart"; readonly libraries: ReadonlyArray }; + +export const planEnableExtension = ( + name: string, + currentLibraries: ReadonlyArray, +): EnableExtensionPlan => { + if (!PRELOAD_REQUIRED_EXTENSIONS.has(name)) return { action: "none" }; + if (currentLibraries.includes(name)) return { action: "none" }; + return { action: "restart", libraries: [...currentLibraries, name] }; +}; +``` + +In `StackLifecycleCoordinator.ts`, add to the service interface and implementation (the coordinator already knows the resolved postgres `dataDir` from config): + +```typescript +enableExtension: (name: string) => + Effect.gen(function* () { + const libs = yield* Effect.promise(() => readPreloadLibraries(pgDataDir)); + const plan = planEnableExtension(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 +enableExtension(name: string): Promise; +// ... +enableExtension: (name) => run(localStack.enableExtension(name)), +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/stack && pnpm vitest run src/enableExtension.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): enableExtension with preload-on-enable restart" +``` + +--- + +### 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; + enableExtension(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`. + - `enableExtension`: if warm → delegate to `StackHandle.enableExtension`; 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; + enableExtension(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 enableExtension(id, extension) { + const pod = warm.get(id); + if (pod) { + await pod.stack.enableExtension(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: `enableExtension` filtering of non-preload extensions happens inside the stack for warm pods; for suspended pods, add the same `PRELOAD_REQUIRED_EXTENSIONS` guard by importing it from `@supabase/stack` (export it from stack's index alongside the pgconf helpers). + +- [ ] **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/fleet/README.md b/packages/fleet/README.md new file mode 100644 index 0000000000..f2245f2866 --- /dev/null +++ b/packages/fleet/README.md @@ -0,0 +1,88 @@ +# @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"; + +await using 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 (default) the pod suspends to zero processes; the port keeps listening. +const branch = await fleet.forkPod("my-worktree", "my-worktree-experiment"); +``` + +## How it works + +- `createPod` clones a per-Postgres-version template data directory (copy-on-write) and + registers the pod's external `dbPort` on an in-process edge proxy. No Postgres process + exists yet -- the pod starts `"suspended"`. +- The first connection to `dbPort` transparently wakes the pod (`"waking"` -> `"warm"`): the + proxy spins up an in-process `@supabase/stack` handle against the pod's data directory and + forwards the connection once Postgres is ready. +- A warm pod with no open connections for `idleMs` (default 5 minutes) suspends itself back to + zero processes (`"warm"` -> `"suspending"` -> `"suspended"`); the port keeps listening for the + next wake. +- `forkPod` suspends the source pod (if warm) and CoW-clones its data directory into a new pod + with its own external port, so the two diverge independently from that point on. +- `wake` / `suspend` let a caller force the transition explicitly instead of waiting on + connection activity or the idle timer. + +## API + +```ts +interface FleetOptions { + readonly root?: string; // defaults to ~/.supabase + readonly idleMs?: number; // defaults to 5 minutes +} + +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; + enableExtension(id: string, extension: string): Promise; + listPods(): Promise>; + dispose(): Promise; +} +``` + +`PodStatus.state` is one of `"suspended" | "waking" | "warm" | "suspending"`. `PodStatus.dbUrl` is +stable across suspend/wake cycles -- the external port never changes for the life of the pod. + +## Phase 1 limitations + +- **Native macOS/Linux only.** No Docker-mode fallback for fleet pods yet. +- **Kill-then-resuspend reconciliation, not adoption.** If the daemon restarts while pods are + warm, it does not reattach to their running processes. Instead it reaps each pod's stale + `postmaster.pid` (killing the leftover Postgres process group) and leaves the pod suspended; + the next connection re-wakes it normally. Pod data is disposable, so this is safe, just not + zero-downtime. +- **HTTP gateway / additional services are not fleet-wired yet.** Wake-on-connect only listens + on the Postgres port. Other stack services (PostgREST, Auth, Realtime, etc.) can be requested + per-pod via `services` and lazily started inside an already-warm pod, but there's no edge + proxy in front of the API port the way there is for `dbPort` -- so only a warm pod's HTTP + services are reachable, and connecting to them does not itself wake a suspended pod. +- **Realtime's WebSocket lazy-start is not covered.** Even inside a warm pod, Realtime's + lazy-start behavior over a persistent WebSocket connection is a known gap versus the + request/response lazy-start story for other HTTP services. + +## Design + +See [`docs/specs/2026-07-07-micro-supabase-stacks-design.md`](../../docs/specs/2026-07-07-micro-supabase-stacks-design.md) for the full design. + +## Deferred to later phases + +- Binary/Docker image optimizations. +- CLI wiring of `supabase start`/`stop` onto `createFleet`. +- HTTP gateway host-based routing across pods, plus edge wake for the API port. +- PSS/CPU benchmark harness with budget assertions in CI. +- True pod adoption across fleet-daemon restarts. +- Warm-template LRU garbage collection. +- `supautils` profile flag enforcement (the manifest field exists; config plumbing comes later). +- Compatibility suite (`CREATE EXTENSION` sweep, dump/restore round-trip). diff --git a/packages/fleet/package.json b/packages/fleet/package.json new file mode 100644 index 0000000000..558faa47a8 --- /dev/null +++ b/packages/fleet/package.json @@ -0,0 +1,43 @@ +{ + "name": "@supabase/fleet", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "nx run-many -t test:unit test:integration --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/stack": "workspace:*" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "@vitest/coverage-istanbul": "catalog:", + "knip": "catalog:", + "oxfmt": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", + "vitest": "catalog:" + }, + "knip": { + "entry": [ + "src/**/*.test.ts", + "tests/**/*.ts" + ], + "ignoreDependencies": [ + "@typescript/native-preview", + "oxfmt", + "oxlint", + "oxlint-tsgolint" + ], + "ignoreBinaries": [ + "nx" + ] + } +} diff --git a/packages/fleet/src/EdgeProxy.ts b/packages/fleet/src/EdgeProxy.ts new file mode 100644 index 0000000000..9e91ea6760 --- /dev/null +++ b/packages/fleet/src/EdgeProxy.ts @@ -0,0 +1,198 @@ +import { connect, createServer, type Server, type Socket } from "node:net"; + +export interface PodUpstream { + readonly host: string; + readonly port: number; +} + +/** + * 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 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 { + 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); + 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(podId, { server, sockets }); + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + this.registrations.delete(podId); + 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(); + }); + }); + } + + 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))); + } +} diff --git a/packages/fleet/src/EdgeProxy.unit.test.ts b/packages/fleet/src/EdgeProxy.unit.test.ts new file mode 100644 index 0000000000..36f4964eeb --- /dev/null +++ b/packages/fleet/src/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", 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(); + }); + + 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", 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", 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", 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", 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", 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", 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", 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", 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/fleet/src/Fleet.integration.test.ts b/packages/fleet/src/Fleet.integration.test.ts new file mode 100644 index 0000000000..16030f8f00 --- /dev/null +++ b/packages/fleet/src/Fleet.integration.test.ts @@ -0,0 +1,83 @@ +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 } }); + 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); + + 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 } }); + 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.manifest.id === "a"); + expect(["warm", "suspended"]).toContain(final?.state); + }, 600_000); +}); diff --git a/packages/fleet/src/Fleet.ts b/packages/fleet/src/Fleet.ts new file mode 100644 index 0000000000..1e8e0b5682 --- /dev/null +++ b/packages/fleet/src/Fleet.ts @@ -0,0 +1,396 @@ +import { rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + createStack, + postgresConnectionUrl, + PRELOAD_REQUIRED_EXTENSIONS, + readPreloadLibraries, + resolvePostgresPassword, + writePreloadLibraries, + type StackHandle, +} from "@supabase/stack"; +import { EdgeProxy, type PodUpstream } from "./EdgeProxy.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, type CreatePodOptions } from "./Provisioner.ts"; +import { reapStalePostmaster } from "./reapStalePostmaster.ts"; +import { TemplateStore } from "./TemplateStore.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; + enableExtension(id: string, extension: string): Promise; + listPods(): Promise>; + dispose(): Promise; +} + +interface WarmPod { + readonly stack: StackHandle; + readonly internalDbPort: 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. + * - `run.pid` files under each pod's directory record which daemon process + * currently owns a warm pod; startup reconciliation uses them as a hint + * that the pod *may* have been running under a previous daemon. The + * actual kill decision, however, 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. Phase 1 only ever runs postgres under a fleet pod (no HTTP + * edge yet), so reaping the postmaster's process group covers every + * process a stale pod could have left behind. This is a phase-1 + * kill-then-suspend policy, not adoption: the spec's 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(); + + const templates = new TemplateStore(join(root, "templates"), postgresPassword); + const pods = new PodRegistry(join(root, "pods")); + const ports = await PortRegistry.load(join(root, "fleet-state.json")); + const provisioner = new Provisioner({ templates, pods, ports, postgresPassword }); + + const states = new Map(); + const warm = new Map(); + const wakesInFlight = new Map>(); + const podLocks = new PodLock(); + let disposed = false; + + 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 => + postgresConnectionUrl({ + user: "postgres", + password: manifest.postgresPassword, + host: "127.0.0.1", + port: manifest.ports.dbPort, + database: "postgres", + }); + + const runPidFile = (id: string): string => join(pods.podDir(id), "run.pid"); + + 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 { + await proxy.unregister(id).catch(() => {}); + await provisioner.destroy(id).catch(() => {}); + states.delete(id); + } + + async function wakeUpstream(id: string): Promise { + if (disposed) throw new Error("fleet is disposed"); + const existing = warm.get(id); + if (existing) return { host: "127.0.0.1", port: existing.internalDbPort }; + // 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, async (): Promise => { + const manifest = await pods.read(id); + if (manifest === undefined) throw new Error(`unknown pod: ${id}`); + states.set(id, "waking"); + const { internalPorts } = manifest; + const internalDbPort = internalPorts.dbPort; + const stack = await createStack({ + mode: "native", + stackRoot: join(pods.podDir(id), "stack"), + port: internalPorts.apiPort, + lazyServices: true, + postgres: { + dataDir: pods.dataDir(id), + version: manifest.versions.postgres, + port: internalDbPort, + password: manifest.postgresPassword, + provisioned: true, + profile: "micro", + }, + postgrest: + manifest.services.postgrest === true + ? { + version: manifest.versions.postgrest, + port: internalPorts.postgrestPort, + } + : false, + auth: + manifest.services.auth === true + ? { version: manifest.versions.auth, port: internalPorts.authPort } + : false, + realtime: false, + edgeRuntime: false, + storage: false, + imgproxy: false, + mailpit: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, + functions: false, + }); + try { + await stack.start(); + await stack.serviceReady("postgres"); + await writeFile(runPidFile(id), String(process.pid)); + warm.set(id, { stack, internalDbPort }); + states.set(id, "warm"); + monitor.track(id); + monitor.recordActivity(id, proxy.openConnections(id)); + return { host: "127.0.0.1", port: internalDbPort }; + } catch (err) { + await stack.dispose().catch(() => {}); + throw err; + } + }) + .catch((err: unknown) => { + states.set(id, "suspended"); + throw err; + }); + const tracked = p.finally(() => { + wakesInFlight.delete(id); + }); + wakesInFlight.set(id, tracked); + return tracked; + } + + 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 { + // 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)); + } + + async function suspendLocked(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(runPidFile(id), { 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 + // (see class doc above). `run.pid` (the daemon's own pid) is only a HINT + // that a pod may have been running under a previous daemon; it's not the + // kill target, since process-compose/createStack spawn postgres + // `detached: true` — its own process group, distinct from the daemon's — + // so `kill(-daemonPid, ...)` would miss postgres entirely and leave a + // stale postmaster running forever. The actual 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`. Phase 1 only ever runs postgres under a fleet pod + // (postgres-only ready gate; no HTTP edge yet), so this fully covers what + // a stale pod could have left behind. 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 { + const manifests = await pods.list(); + await ports.reconcile( + new Map( + manifests.map((manifest) => [ + manifest.id, + { ports: manifest.ports, internalPorts: manifest.internalPorts }, + ]), + ), + ); + for (const manifest of manifests) { + await reapStalePostmaster(pods.dataDir(manifest.id)); + await rm(runPidFile(manifest.id), { force: true }); + await registerEdge(manifest); + } + } catch (err) { + await proxy.close().catch(() => {}); + throw err; + } + + const handle: FleetHandle = { + async createPod(opts) { + return podLocks.withLock(opts.id, async () => { + const manifest = await provisioner.create(opts); + try { + await registerEdge(manifest); + return status(manifest); + } catch (err) { + await rollbackProvisionedPod(manifest.id); + throw err; + } + }); + }, + async 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. + await podLocks.withLock(id, async () => { + await suspendLocked(id); + await proxy.unregister(id); + await provisioner.destroy(id); + states.delete(id); + }); + }, + async resetPod(id) { + await podLocks.withLock(id, async () => { + await suspendLocked(id); + await provisioner.reset(id); + }); + }, + async forkPod(sourceId, newId) { + if (sourceId === newId) throw new Error(`pod already exists: ${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. + const manifest = await withPodLocks([sourceId, newId], async () => { + await suspendLocked(sourceId); + const forked = await provisioner.fork(sourceId, newId); + try { + await registerEdge(forked); + return forked; + } catch (err) { + await rollbackProvisionedPod(forked.id); + throw err; + } + }); + return status(manifest); + }, + async wake(id) { + await wakeUpstream(id); + }, + suspend, + // Preload-only semantics when suspended: if the pod is warm, this + // enables `extension` immediately via the live StackHandle (CREATE + // EXTENSION, possibly after a preload-triggered restart) regardless of + // which extension it is. If the pod is SUSPENDED, there is no live + // postgres to run CREATE EXTENSION against, so this can only durably + // record intent for extensions that need a preload library — those get + // appended to the data dir's preload-libraries config so the next wake + // picks them up. For a suspended pod and a NON-preload extension (i.e. + // not in `PRELOAD_REQUIRED_EXTENSIONS`), this method is a silent no-op: + // nothing is persisted, and the extension is NOT created on the next + // wake. Callers that need a non-preload extension enabled on a + // currently-suspended pod must `wake()` it first. + async enableExtension(id, extension) { + await podLocks.withLock(id, async () => { + const pod = warm.get(id); + if (pod) { + await pod.stack.enableExtension(extension); + return; + } + const manifest = await pods.read(id); + if (manifest === undefined) throw new Error(`unknown pod: ${id}`); + if (!PRELOAD_REQUIRED_EXTENSIONS.has(extension)) return; + 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() { + disposed = true; + await proxy.close(); + await Promise.allSettled(wakesInFlight.values()); + for (const id of warm.keys()) await suspend(id); + }, + async [Symbol.asyncDispose]() { + await handle.dispose(); + }, + }; + return handle; +} diff --git a/packages/fleet/src/Fleet.unit.test.ts b/packages/fleet/src/Fleet.unit.test.ts new file mode 100644 index 0000000000..3c586a5c4a --- /dev/null +++ b/packages/fleet/src/Fleet.unit.test.ts @@ -0,0 +1,85 @@ +import { type AddressInfo, createServer } from "node:net"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { AllocatedPorts } from "@supabase/stack"; +import { createFleet } from "./Fleet.ts"; +import { PodRegistry } from "./PodRegistry.ts"; +import type { PodManifest } from "./PodManifest.ts"; + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + server.close(() => resolve(address.port)); + }); + }); +} + +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 ports(dbPort: number, apiPort: number): AllocatedPorts { + return { + dbPort, + apiPort, + authPort: apiPort + 1, + postgrestPort: apiPort + 2, + postgrestAdminPort: apiPort + 3, + edgeRuntimePort: apiPort + 4, + edgeRuntimeInspectorPort: apiPort + 5, + realtimePort: apiPort + 6, + storagePort: apiPort + 7, + imgproxyPort: apiPort + 8, + mailpitPort: apiPort + 9, + mailpitSmtpPort: apiPort + 10, + mailpitPop3Port: apiPort + 11, + pgmetaPort: apiPort + 12, + studioPort: apiPort + 13, + analyticsPort: apiPort + 14, + poolerPort: apiPort + 15, + poolerApiPort: apiPort + 16, + }; +} + +function manifest(id: string, dbPort: number, apiPort: number): PodManifest { + return { + id, + versions: { postgres: "17.6.1.143" }, + services: {}, + flags: { supautils: false }, + ports: ports(dbPort, apiPort), + internalPorts: ports(dbPort - 10_000, apiPort - 10_000), + postgresPassword: "postgres", + createdAt: "2026-07-08T00:00:00.000Z", + }; +} + +describe("createFleet", () => { + 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 freePort(); + const apiPort = await freePort(); + + await pods.write(manifest("pod-a", dbPort, apiPort)); + await pods.write(manifest("pod-b", dbPort, apiPort + 1)); + + 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/fleet/src/IdleMonitor.ts b/packages/fleet/src/IdleMonitor.ts new file mode 100644 index 0000000000..bbd5b4ca45 --- /dev/null +++ b/packages/fleet/src/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/fleet/src/IdleMonitor.unit.test.ts b/packages/fleet/src/IdleMonitor.unit.test.ts new file mode 100644 index 0000000000..6916091e80 --- /dev/null +++ b/packages/fleet/src/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/fleet/src/PodManifest.ts b/packages/fleet/src/PodManifest.ts new file mode 100644 index 0000000000..8fbcf5be3b --- /dev/null +++ b/packages/fleet/src/PodManifest.ts @@ -0,0 +1,60 @@ +import { createHash } from "node:crypto"; +import { + fillServiceVersionManifest, + type AllocatedPorts, + type ServiceName, + type VersionManifest, +} from "@supabase/stack"; + +export interface PodManifest { + readonly id: string; + readonly versions: Partial; + readonly services: Partial>; + readonly flags: { readonly supautils: boolean }; + readonly ports: AllocatedPorts; + readonly internalPorts: AllocatedPorts; + readonly postgresPassword: string; + 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, +): Partial => { + const full = fillServiceVersionManifest(versions); + const resolved: Partial> = { postgres: full.postgres }; + for (const service of new Set(enabledServices)) { + resolved[service] = full[service]; + } + return resolved; +}; diff --git a/packages/fleet/src/PodManifest.unit.test.ts b/packages/fleet/src/PodManifest.unit.test.ts new file mode 100644 index 0000000000..4fd76f7bf6 --- /dev/null +++ b/packages/fleet/src/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/fleet/src/PodRegistry.ts b/packages/fleet/src/PodRegistry.ts new file mode 100644 index 0000000000..bb4a82a5de --- /dev/null +++ b/packages/fleet/src/PodRegistry.ts @@ -0,0 +1,263 @@ +import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { SERVICE_NAMES } from "@supabase/stack"; +import type { AllocatedPorts, ServiceName } from "@supabase/stack"; +import type { PodManifest } from "./PodManifest.ts"; + +const POD_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const SERVICE_NAME_SET = new Set(SERVICE_NAMES); + +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 isFleetPort(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value > 10_000 && value <= 65_535; +} + +function parsePorts(value: unknown): AllocatedPorts | undefined { + if (!isRecord(value)) return undefined; + if ( + !isFleetPort(value.dbPort) || + !isFleetPort(value.apiPort) || + !isFleetPort(value.authPort) || + !isFleetPort(value.postgrestPort) || + !isFleetPort(value.postgrestAdminPort) || + !isFleetPort(value.edgeRuntimePort) || + !isFleetPort(value.edgeRuntimeInspectorPort) || + !isFleetPort(value.realtimePort) || + !isFleetPort(value.storagePort) || + !isFleetPort(value.imgproxyPort) || + !isFleetPort(value.mailpitPort) || + !isFleetPort(value.mailpitSmtpPort) || + !isFleetPort(value.mailpitPop3Port) || + !isFleetPort(value.pgmetaPort) || + !isFleetPort(value.studioPort) || + !isFleetPort(value.analyticsPort) || + !isFleetPort(value.poolerPort) || + !isFleetPort(value.poolerApiPort) + ) { + return undefined; + } + return { + dbPort: value.dbPort, + apiPort: value.apiPort, + authPort: value.authPort, + postgrestPort: value.postgrestPort, + postgrestAdminPort: value.postgrestAdminPort, + edgeRuntimePort: value.edgeRuntimePort, + edgeRuntimeInspectorPort: value.edgeRuntimeInspectorPort, + realtimePort: value.realtimePort, + storagePort: value.storagePort, + imgproxyPort: value.imgproxyPort, + mailpitPort: value.mailpitPort, + mailpitSmtpPort: value.mailpitSmtpPort, + mailpitPop3Port: value.mailpitPop3Port, + pgmetaPort: value.pgmetaPort, + studioPort: value.studioPort, + analyticsPort: value.analyticsPort, + poolerPort: value.poolerPort, + poolerApiPort: value.poolerApiPort, + }; +} + +function parseVersions(value: unknown): PodManifest["versions"] | 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): Partial> | undefined { + if (!isRecord(value)) return undefined; + const services: Partial> = {}; + for (const [name, enabled] of Object.entries(value)) { + const service = serviceNameFrom(name); + if (service === undefined || !SERVICE_NAME_SET.has(name) || typeof enabled !== "boolean") { + return undefined; + } + services[service] = enabled; + } + return services; +} + +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); + const ports = parsePorts(value.ports); + const internalPorts = parsePorts(value.internalPorts); + if ( + versions === undefined || + services === undefined || + ports === undefined || + internalPorts === undefined + ) { + return undefined; + } + if (!isRecord(value.flags) || typeof value.flags.supautils !== "boolean") return undefined; + if (typeof value.postgresPassword !== "string" || value.postgresPassword.length === 0) { + return undefined; + } + if (typeof value.createdAt !== "string" || Number.isNaN(Date.parse(value.createdAt))) { + return undefined; + } + return { + id: value.id, + versions, + services, + flags: { supautils: value.flags.supautils }, + ports, + internalPorts, + postgresPassword: value.postgresPassword, + 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"); + } + + async read(id: string): Promise { + const raw = await readFile(join(this.podDir(id), "pod.json"), "utf8").catch(() => undefined); + 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 dir = this.podDir(manifest.id); + await mkdir(dir, { recursive: true }); + const tmp = join(dir, `pod.json.tmp-${process.pid}-${Date.now()}`); + await writeFile(tmp, JSON.stringify(manifest, null, 2)); + await rename(tmp, join(dir, "pod.json")); + } + + async list(): Promise { + const entries = await readdir(this.podsRoot).catch(() => [] as string[]); + const manifests = await Promise.all(entries.filter(isValidPodId).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/fleet/src/PodRegistry.unit.test.ts b/packages/fleet/src/PodRegistry.unit.test.ts new file mode 100644 index 0000000000..7dd9a6bc75 --- /dev/null +++ b/packages/fleet/src/PodRegistry.unit.test.ts @@ -0,0 +1,69 @@ +import { 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 { AllocatedPorts } from "@supabase/stack"; +import { PodRegistry } from "./PodRegistry.ts"; + +function ports(dbPort: number, apiPort: number): AllocatedPorts { + return { + dbPort, + apiPort, + authPort: apiPort + 1, + postgrestPort: apiPort + 2, + postgrestAdminPort: apiPort + 3, + edgeRuntimePort: apiPort + 4, + edgeRuntimeInspectorPort: apiPort + 5, + realtimePort: apiPort + 6, + storagePort: apiPort + 7, + imgproxyPort: apiPort + 8, + mailpitPort: apiPort + 9, + mailpitSmtpPort: apiPort + 10, + mailpitPop3Port: apiPort + 11, + pgmetaPort: apiPort + 12, + studioPort: apiPort + 13, + analyticsPort: apiPort + 14, + poolerPort: apiPort + 15, + poolerApiPort: apiPort + 16, + }; +} + +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 }, + ports: ports(55000, 55001), + internalPorts: ports(45000, 45001), + postgresPassword: "postgres", + 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([]); + }); +}); diff --git a/packages/fleet/src/PortRegistry.ts b/packages/fleet/src/PortRegistry.ts new file mode 100644 index 0000000000..5aa9c9a291 --- /dev/null +++ b/packages/fleet/src/PortRegistry.ts @@ -0,0 +1,296 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { PORT_FIELDS, type AllocatedPorts } from "@supabase/stack"; + +export interface PodPorts { + readonly ports: AllocatedPorts; + readonly internalPorts: AllocatedPorts; +} + +interface PortState { + readonly basePort: number; + readonly internalBasePort: number; + readonly pods: Record; +} + +const DEFAULT_BASE_PORT = 55000; +const DEFAULT_INTERNAL_BASE_PORT = 45000; +const INTERNAL_MAX_PORT = DEFAULT_BASE_PORT - 1; +const MAX_PORT = 65_535; + +function freshState(): PortState { + return { basePort: DEFAULT_BASE_PORT, internalBasePort: DEFAULT_INTERNAL_BASE_PORT, pods: {} }; +} + +function getOwnPodPorts(pods: Record, podId: string): PodPorts | undefined { + return Object.hasOwn(pods, podId) ? pods[podId] : 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 > 10_000 && value <= MAX_PORT + ); +} + +function isValidPortSet(value: unknown): value is AllocatedPorts { + if (!isRecord(value)) return false; + for (const field of PORT_FIELDS) { + if (!isFleetPort(value[field])) return false; + } + return true; +} + +function isValidPodPorts(value: unknown): value is PodPorts { + if (!isRecord(value)) return false; + return isValidPortSet(value.ports) && isValidPortSet(value.internalPorts); +} + +function isValidState(value: unknown): value is PortState { + if (!isRecord(value)) return false; + const { basePort, internalBasePort, pods } = value; + if (!isFleetPort(basePort) || basePort < DEFAULT_BASE_PORT) return false; + if (!isFleetPort(internalBasePort) || internalBasePort > INTERNAL_MAX_PORT) return false; + if (!isRecord(pods)) return false; + for (const allocation of Object.values(pods)) { + if (!isValidPodPorts(allocation)) return false; + } + return true; +} + +function allocatedPorts(allocation: PodPorts): ReadonlyArray { + return [ + ...PORT_FIELDS.map((field) => allocation.ports[field]), + ...PORT_FIELDS.map((field) => allocation.internalPorts[field]), + ]; +} + +function sameAllocation(a: PodPorts, b: PodPorts): boolean { + return PORT_FIELDS.every( + (field) => + a.ports[field] === b.ports[field] && a.internalPorts[field] === b.internalPorts[field], + ); +} + +function allocatePortSet(next: () => number): AllocatedPorts { + return { + dbPort: next(), + apiPort: next(), + authPort: next(), + postgrestPort: next(), + postgrestAdminPort: next(), + edgeRuntimePort: next(), + edgeRuntimeInspectorPort: next(), + realtimePort: next(), + storagePort: next(), + imgproxyPort: next(), + mailpitPort: next(), + mailpitSmtpPort: next(), + mailpitPop3Port: next(), + pgmetaPort: next(), + studioPort: next(), + analyticsPort: next(), + poolerPort: next(), + poolerApiPort: next(), + }; +} + +/** + * Persistent registry mapping pod IDs to their allocated stack ports, backed + * by a single JSON state file on disk. + * + * Design assumptions: + * - **Single owner process.** Exactly one `PortRegistry` instance (the fleet + * daemon) is expected to read and write the state file at a time. There is + * no file locking or cross-process coordination, so concurrent writers will + * silently lose updates (last write wins). + * - **No host-level port probing.** The registry never checks whether a port + * is actually free on the host; it only tracks what it has handed out + * itself. The daemon owns the 55000+ range by convention, so nothing else + * on the host is expected to bind those ports. Public proxy listeners use + * the 55000+ range, while in-process stack services use separately allocated + * ports below that public range. + * - **Corrupt-state recovery.** If the state file is missing, unreadable as + * JSON, or structurally invalid, `load()` never throws. Instead it + * quarantines the bad file (renaming it to `.corrupt`, replacing + * any previous quarantine) and starts from fresh empty state. Since pod + * ports are also duplicated in each pod's own manifest (`pod.json`), the + * daemon reconciles the registry from valid manifests after such a reset. + * - **In-process serialization.** Mutating operations are queued so concurrent + * lifecycle calls in the owner process cannot interleave writes to the shared + * temporary state file. + */ +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(() => undefined); + 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): PodPorts | undefined { + return getOwnPodPorts(this.state.pods, podId); + } + + async allocate(podId: string): Promise { + return this.withMutation(async () => { + const existing = getOwnPodPorts(this.state.pods, podId); + if (existing) return existing; + const used = new Set( + Object.values(this.state.pods).flatMap((allocation) => allocatedPorts(allocation)), + ); + let publicCandidate = this.state.basePort; + let internalCandidate = this.state.internalBasePort; + const nextPublic = (): number => { + while (used.has(publicCandidate)) publicCandidate += 1; + if (publicCandidate > MAX_PORT) { + throw new Error("PortRegistry: exhausted fleet port range"); + } + const port = publicCandidate; + used.add(port); + publicCandidate += 1; + return port; + }; + const nextInternal = (): number => { + while (used.has(internalCandidate)) internalCandidate += 1; + if (internalCandidate > INTERNAL_MAX_PORT) { + throw new Error("PortRegistry: exhausted internal fleet port range"); + } + const port = internalCandidate; + used.add(port); + internalCandidate += 1; + return port; + }; + const allocation = { + ports: allocatePortSet(nextPublic), + internalPorts: allocatePortSet(nextInternal), + }; + this.state = { ...this.state, pods: { ...this.state.pods, [podId]: allocation } }; + await this.persist(); + return allocation; + }); + } + + /** + * Records a known allocation (typically read back from a pod's own + * manifest) without scanning for free ports. Idempotent when the pod + * already holds exactly these ports. Throws if the pod already holds + * different ports, or if either port is already assigned to a different + * pod. + */ + async restore(podId: string, ports: PodPorts): Promise { + await this.withMutation(async () => { + const existing = getOwnPodPorts(this.state.pods, podId); + if (existing) { + if (sameAllocation(existing, ports)) { + return; + } + throw new Error( + `PortRegistry: cannot restore pod "${podId}" with ports ${JSON.stringify(ports)}; ` + + `it is already recorded with different ports ${JSON.stringify(existing)}`, + ); + } + + const restoredPorts = allocatedPorts(ports); + if (new Set(restoredPorts).size !== restoredPorts.length) { + throw new Error( + `PortRegistry: cannot restore pod "${podId}" with ports ${JSON.stringify(ports)}; ` + + "allocation contains duplicate ports", + ); + } + const restoredPortSet = new Set(restoredPorts); + for (const [otherPodId, otherPorts] of Object.entries(this.state.pods)) { + if (allocatedPorts(otherPorts).some((port) => restoredPortSet.has(port))) { + throw new Error( + `PortRegistry: cannot restore pod "${podId}" with ports ${JSON.stringify(ports)}; ` + + `port already assigned to pod "${otherPodId}"`, + ); + } + } + + this.state = { ...this.state, pods: { ...this.state.pods, [podId]: ports } }; + await this.persist(); + }); + } + + async release(podId: string): Promise { + await this.withMutation(async () => { + const rest = { ...this.state.pods }; + delete rest[podId]; + this.state = { ...this.state, pods: rest }; + await this.persist(); + }); + } + + async reconcile(podPorts: ReadonlyMap): Promise { + await this.withMutation(async () => { + const nextPods: Record = {}; + const used = new Map(); + for (const [podId, ports] of podPorts) { + for (const port of allocatedPorts(ports)) { + const owner = used.get(port); + if (owner !== undefined) { + throw new Error( + `PortRegistry: cannot reconcile pod "${podId}" with ports ${JSON.stringify(ports)}; ` + + `port already assigned to pod "${owner}"`, + ); + } + used.set(port, podId); + } + nextPods[podId] = ports; + } + this.state = { ...this.state, pods: nextPods }; + 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/fleet/src/PortRegistry.unit.test.ts b/packages/fleet/src/PortRegistry.unit.test.ts new file mode 100644 index 0000000000..8f26eb3169 --- /dev/null +++ b/packages/fleet/src/PortRegistry.unit.test.ts @@ -0,0 +1,260 @@ +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 type { AllocatedPorts } from "@supabase/stack"; +import { PortRegistry, type PodPorts } from "./PortRegistry.ts"; + +function ports(dbPort: number, apiPort: number): AllocatedPorts { + return { + dbPort, + apiPort, + authPort: apiPort + 1, + postgrestPort: apiPort + 2, + postgrestAdminPort: apiPort + 3, + edgeRuntimePort: apiPort + 4, + edgeRuntimeInspectorPort: apiPort + 5, + realtimePort: apiPort + 6, + storagePort: apiPort + 7, + imgproxyPort: apiPort + 8, + mailpitPort: apiPort + 9, + mailpitSmtpPort: apiPort + 10, + mailpitPop3Port: apiPort + 11, + pgmetaPort: apiPort + 12, + studioPort: apiPort + 13, + analyticsPort: apiPort + 14, + poolerPort: apiPort + 15, + poolerApiPort: apiPort + 16, + }; +} + +function podPorts( + dbPort: number, + apiPort: number, + internalDbPort = dbPort - 10_000, + internalApiPort = apiPort - 10_000, +): PodPorts { + return { + ports: ports(dbPort, apiPort), + internalPorts: ports(internalDbPort, internalApiPort), + }; +} + +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.ports.dbPort, a.ports.apiPort, b.ports.dbPort, b.ports.apiPort]).size).toBe( + 4, + ); + expect(a.internalPorts.dbPort).toBe(45000); + expect(a.internalPorts.apiPort).toBe(45001); + + 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.ports.dbPort).toBe(a1.ports.dbPort); // freed ports are reusable + expect(c.internalPorts.dbPort).toBe(a1.internalPorts.dbPort); + }); + + it("treats inherited property names as ordinary pod ids", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const reg = await PortRegistry.load(file); + + const ports = await reg.allocate("constructor"); + + expect(ports.ports).toEqual(expect.objectContaining({ dbPort: 55000, apiPort: 55001 })); + expect(ports.internalPorts).toEqual(expect.objectContaining({ dbPort: 45000, apiPort: 45001 })); + expect( + new Set([...Object.values(ports.ports), ...Object.values(ports.internalPorts)]).size, + ).toBe(36); + expect(reg.get("constructor")).toEqual(ports); + }); + + it("serializes concurrent mutations before persisting", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const reg = await PortRegistry.load(file); + + const pods = Array.from({ length: 20 }, (_, index) => `pod-${index}`); + await Promise.all(pods.map((pod) => reg.allocate(pod))); + await Promise.all(pods.slice(0, 10).map((pod) => reg.release(pod))); + await Promise.all(pods.slice(0, 10).map((pod) => reg.allocate(`${pod}-new`))); + + const reloaded = await PortRegistry.load(file); + const restored = [...pods.slice(10), ...pods.slice(0, 10).map((pod) => `${pod}-new`)].map( + (pod) => reloaded.get(pod), + ); + expect(restored.every((ports) => ports !== undefined)).toBe(true); + }); + + it("quarantines a corrupt state file and loads with fresh empty state", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const garbage = "{not valid json at all"; + await writeFile(file, garbage); + + const reg = await PortRegistry.load(file); + + // Loads successfully with fresh empty state. + expect(reg.get("anything")).toBeUndefined(); + const allocated = await reg.allocate("pod-a"); + expect(allocated.ports.dbPort).toBeGreaterThanOrEqual(55000); + expect(allocated.internalPorts.dbPort).toBeGreaterThanOrEqual(45000); + + // Original bad bytes are preserved in the quarantine file. + const quarantined = await readFile(`${file}.corrupt`, "utf8"); + expect(quarantined).toBe(garbage); + }); + + it("quarantines a structurally invalid state file (bad basePort/pods)", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const badStructure = JSON.stringify({ + basePort: "not-a-number", + internalBasePort: 45000, + pods: [], + }); + await writeFile(file, badStructure); + + const reg = await PortRegistry.load(file); + expect(reg.get("anything")).toBeUndefined(); + const allocated = await reg.allocate("pod-a"); + expect(allocated.ports.dbPort).toBeGreaterThanOrEqual(55000); + + const quarantined = await readFile(`${file}.corrupt`, "utf8"); + expect(quarantined).toBe(badStructure); + }); + + it("quarantines saved pod entries with invalid port records", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const badStructure = JSON.stringify({ + basePort: 55000, + internalBasePort: 45000, + pods: { + "pod-a": podPorts(55010, 55011), + }, + }); + const parsed = JSON.parse(badStructure); + parsed.pods["pod-a"].ports.poolerApiPort = "55027"; + const raw = JSON.stringify(parsed); + await writeFile(file, raw); + + const reg = await PortRegistry.load(file); + expect(reg.get("pod-a")).toBeUndefined(); + const allocated = await reg.allocate("pod-a"); + expect(allocated.ports).toEqual(expect.objectContaining({ dbPort: 55000, apiPort: 55001 })); + + const quarantined = await readFile(`${file}.corrupt`, "utf8"); + expect(quarantined).toBe(raw); + }); + + it("overwrites any previous quarantine file", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + await writeFile(`${file}.corrupt`, "old-quarantine"); + const garbage = "{ still bad"; + await writeFile(file, garbage); + + await PortRegistry.load(file); + + const quarantined = await readFile(`${file}.corrupt`, "utf8"); + expect(quarantined).toBe(garbage); + }); + + describe("restore", () => { + it("records known allocations from pod manifests without scanning", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const reg = await PortRegistry.load(file); + + await reg.restore("pod-a", podPorts(55010, 55011, 45010, 45011)); + await reg.restore("pod-b", podPorts(55030, 55031, 45030, 45031)); + + expect(reg.get("pod-a")?.ports).toEqual( + expect.objectContaining({ dbPort: 55010, apiPort: 55011 }), + ); + expect(reg.get("pod-a")?.internalPorts).toEqual( + expect.objectContaining({ dbPort: 45010, apiPort: 45011 }), + ); + expect(reg.get("pod-b")?.ports).toEqual( + expect.objectContaining({ dbPort: 55030, apiPort: 55031 }), + ); + + // New allocations must skip the restored ports. + const next = await reg.allocate("new-pod"); + expect([next.ports.dbPort, next.ports.apiPort]).not.toContain(55010); + expect([next.ports.dbPort, next.ports.apiPort]).not.toContain(55011); + expect([next.ports.dbPort, next.ports.apiPort]).not.toContain(55030); + expect([next.ports.dbPort, next.ports.apiPort]).not.toContain(55031); + expect([next.internalPorts.dbPort, next.internalPorts.apiPort]).not.toContain(45010); + expect([next.internalPorts.dbPort, next.internalPorts.apiPort]).not.toContain(45011); + + // Persisted, so a reload sees the restored allocation. + const reloaded = await PortRegistry.load(file); + expect(reloaded.get("pod-a")?.ports).toEqual( + expect.objectContaining({ dbPort: 55010, apiPort: 55011 }), + ); + }); + + it("is idempotent when restoring identical ports for the same pod", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const reg = await PortRegistry.load(file); + + await reg.restore("pod-a", podPorts(55010, 55011, 45010, 45011)); + await expect( + reg.restore("pod-a", podPorts(55010, 55011, 45010, 45011)), + ).resolves.toBeUndefined(); + expect(reg.get("pod-a")?.ports).toEqual( + expect.objectContaining({ dbPort: 55010, apiPort: 55011 }), + ); + }); + + it("throws if the pod already has different ports recorded", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const reg = await PortRegistry.load(file); + + await reg.restore("pod-a", podPorts(55010, 55011, 45010, 45011)); + await expect(reg.restore("pod-a", podPorts(55010, 55099, 45010, 45099))).rejects.toThrow(); + }); + + it("throws if a port is already assigned to a different pod", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const reg = await PortRegistry.load(file); + + await reg.restore("pod-a", podPorts(55010, 55011, 45010, 45011)); + await expect(reg.restore("pod-b", podPorts(55010, 55030, 45030, 45031))).rejects.toThrow(); + await expect(reg.restore("pod-b", podPorts(55030, 55011, 45030, 45031))).rejects.toThrow(); + await expect(reg.restore("pod-b", podPorts(55030, 55031, 45010, 45030))).rejects.toThrow(); + }); + + it("throws if a restored db port collides with another pod's api port", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + const reg = await PortRegistry.load(file); + + await reg.restore("pod-a", podPorts(55010, 55011, 45010, 45011)); + await expect(reg.restore("pod-b", podPorts(55011, 55030, 45030, 45031))).rejects.toThrow(); + await expect(reg.restore("pod-b", podPorts(55030, 55010, 45030, 45031))).rejects.toThrow(); + }); + + it("keeps internal allocations out of the public proxy range", async () => { + const file = join(await mkdtemp(join(tmpdir(), "ports-")), "state.json"); + await writeFile(file, JSON.stringify({ basePort: 65000, internalBasePort: 45000, pods: {} })); + const reg = await PortRegistry.load(file); + + const allocated = await reg.allocate("pod-high"); + + expect(allocated.ports.dbPort).toBe(65000); + expect(allocated.internalPorts.dbPort).toBe(45000); + expect(Object.values(allocated.internalPorts).every((port) => port < 55000)).toBe(true); + }); + }); +}); diff --git a/packages/fleet/src/Provisioner.integration.test.ts b/packages/fleet/src/Provisioner.integration.test.ts new file mode 100644 index 0000000000..2d3ccb0552 --- /dev/null +++ b/packages/fleet/src/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.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 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/fleet/src/Provisioner.ts b/packages/fleet/src/Provisioner.ts new file mode 100644 index 0000000000..dc8709cf91 --- /dev/null +++ b/packages/fleet/src/Provisioner.ts @@ -0,0 +1,160 @@ +import { rename, rm } from "node:fs/promises"; +import { + SERVICE_NAMES, + validateEnabledServiceDependencies, + type ServiceName, + type VersionManifest, +} from "@supabase/stack"; +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; +} + +const NATIVE_FLEET_SERVICES = new Set(["postgres", "postgrest", "auth"]); + +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; +} + +/** + * 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; + 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 = SERVICE_NAMES.filter((name) => opts.services?.[name] === true); + const dependencyError = validateEnabledServiceDependencies(new Set(enabled)); + if (dependencyError !== undefined) { + throw new Error(dependencyError); + } + const unsupported = enabled.filter((service) => !NATIVE_FLEET_SERVICES.has(service)); + if (unsupported.length > 0) { + throw new Error( + `fleet native mode only supports postgrest and auth pods; unsupported services: ${unsupported.join(", ")}`, + ); + } + 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: opts.services ?? {}, + flags: { supautils: opts.flags?.supautils ?? false }, + ports: allocated.ports, + internalPorts: allocated.internalPorts, + postgresPassword, + createdAt: new Date().toISOString(), + }; + await pods.write(manifest); + return manifest; + } catch (err) { + await ports.release(opts.id).catch(() => {}); + await rm(pods.dataDir(opts.id), { recursive: true, force: true }).catch(() => {}); + await rm(pods.podDir(opts.id), { recursive: true, force: true }).catch(() => {}); + throw err; + } + } + + /** Re-clones the pod's data dir from the base template of its postgres version. */ + 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 template = 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; + await cloneDir(template, tmpDataDir); + try { + await rename(dataDir, backupDataDir).then( + () => { + backedUp = true; + }, + (error: unknown) => { + if (errorCode(error) !== "ENOENT") throw error; + }, + ); + await rename(tmpDataDir, dataDir); + await pods.write({ ...manifest, postgresPassword }); + await rm(backupDataDir, { recursive: true, force: true }); + } catch (error) { + if (backedUp) { + await rm(dataDir, { recursive: true, force: true }).catch(() => {}); + await rename(backupDataDir, dataDir).catch(() => {}); + } + throw error; + } finally { + await rm(tmpDataDir, { recursive: true, force: true }).catch(() => {}); + } + } + + /** 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); + try { + await cloneDir(pods.dataDir(sourceId), pods.dataDir(newId)); + const manifest: PodManifest = { + ...source, + id: newId, + ports: allocated.ports, + internalPorts: allocated.internalPorts, + createdAt: new Date().toISOString(), + }; + await pods.write(manifest); + return manifest; + } catch (err) { + await ports.release(newId).catch(() => {}); + await rm(pods.dataDir(newId), { recursive: true, force: true }).catch(() => {}); + await rm(pods.podDir(newId), { recursive: true, force: true }).catch(() => {}); + throw err; + } + } + + async destroy(id: string): Promise { + const { pods, ports } = this.deps; + await pods.remove(id); + await ports.release(id); + } +} diff --git a/packages/fleet/src/Provisioner.unit.test.ts b/packages/fleet/src/Provisioner.unit.test.ts new file mode 100644 index 0000000000..9446b00518 --- /dev/null +++ b/packages/fleet/src/Provisioner.unit.test.ts @@ -0,0 +1,232 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DEFAULT_VERSIONS, type ServiceName, type VersionManifest } from "@supabase/stack"; +import { describe, expect, it } from "vitest"; +import { PodRegistry } from "./PodRegistry.ts"; +import { PortRegistry } from "./PortRegistry.ts"; +import { Provisioner } 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): TemplateStore { + return { + async ensureBaseTemplate(_postgresVersion: string): Promise { + return templateDir; + }, + async ensureWarmTemplate( + _versions: Partial, + _enabledServices: ReadonlyArray, + ): Promise { + 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 templates = fakeTemplateStore(templateDir); + return { + p: new Provisioner({ templates, pods, ports, postgresPassword: "secret-password" }), + pods, + ports, + podsRoot, + }; +} + +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({ + ports: manifest.ports, + internalPorts: manifest.internalPorts, + }); + 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("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({ + ports: first.ports, + internalPorts: first.internalPorts, + }); + }); + + 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: true }, + 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: true }, + }), + ).rejects.toThrow(/imgproxy requires storage/); + + expect(ports.get("bad")).toBeUndefined(); + expect(await podsRootEntries(podsRoot)).not.toContain("bad"); + }); + + it("rejects services that cannot run in native fleet mode 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: { storage: true }, + }), + ).rejects.toThrow(/native mode/); + + expect(ports.get("bad")).toBeUndefined(); + expect(await podsRootEntries(podsRoot)).not.toContain("bad"); + }); + }); + + describe("reset", () => { + 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.ports).not.toEqual(source.ports); + expect(forked.internalPorts).not.toEqual(source.internalPorts); + expect(ports.get("dst")).toEqual({ + ports: forked.ports, + internalPorts: forked.internalPorts, + }); + 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, ports, podsRoot } = await makeHarness(templateDir); + + await p.create({ id: "src", versions: { postgres: PG_VERSION } }); + + // Force the fork's clone step to fail deterministically: cloneDir + // refuses to clone into a destination that already exists. + await mkdir(join(podsRoot, "dst", "data"), { recursive: true }); + + await expect(p.fork("src", "dst")).rejects.toThrow(); + + expect(ports.get("dst")).toBeUndefined(); + // The pre-created dest dir is removed as part of failure cleanup too. + expect(await podsRootEntries(podsRoot)).toContain("src"); + const dstDataEntries = await readdir(join(podsRoot, "dst", "data")).catch(() => undefined); + expect(dstDataEntries).toBeUndefined(); + }); + + 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({ + ports: source.ports, + internalPorts: source.internalPorts, + }); + expect(ports.get("dst")).toEqual({ + ports: existing.ports, + internalPorts: existing.internalPorts, + }); + }); + }); +}); diff --git a/packages/fleet/src/TemplateStore.integration.test.ts b/packages/fleet/src/TemplateStore.integration.test.ts new file mode 100644 index 0000000000..95f1ba2cd5 --- /dev/null +++ b/packages/fleet/src/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/fleet/src/TemplateStore.ts b/packages/fleet/src/TemplateStore.ts new file mode 100644 index 0000000000..b6e178b78a --- /dev/null +++ b/packages/fleet/src/TemplateStore.ts @@ -0,0 +1,272 @@ +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 type { ServiceName, VersionManifest } from "@supabase/stack"; +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", + postgres: { + version: postgresVersion, + dataDir: buildDataDir, + password: postgresPassword, + }, + 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, + }); + try { + await stack.start(); + await stack.ready(); + } finally { + 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: Partial, + 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 createStack({ + mode: "native", + postgres: { + version: pgVersion, + dataDir: buildDataDir, + password: postgresPassword, + provisioned: true, + profile: "micro", + }, + postgrest: enabledServices.includes("postgrest") + ? { version: resolvedVersions.postgrest } + : false, + auth: enabledServices.includes("auth") ? { version: resolvedVersions.auth } : false, + edgeRuntime: enabledServices.includes("edge-runtime") + ? { version: resolvedVersions["edge-runtime"] } + : false, + realtime: enabledServices.includes("realtime") + ? { version: resolvedVersions.realtime } + : false, + storage: enabledServices.includes("storage") + ? { version: resolvedVersions.storage } + : false, + imgproxy: enabledServices.includes("imgproxy") + ? { version: resolvedVersions.imgproxy } + : false, + mailpit: enabledServices.includes("mailpit") + ? { version: resolvedVersions.mailpit } + : false, + pgmeta: enabledServices.includes("pgmeta") + ? { version: resolvedVersions.pgmeta } + : false, + studio: enabledServices.includes("studio") + ? { version: resolvedVersions.studio } + : false, + analytics: enabledServices.includes("analytics") + ? { version: resolvedVersions.analytics } + : false, + vector: enabledServices.includes("vector") + ? { version: resolvedVersions.vector } + : false, + pooler: enabledServices.includes("pooler") + ? { version: resolvedVersions.pooler } + : false, + functions: false, + }); + try { + await stack.start(); + await stack.ready(); + } finally { + await stack.dispose(); + } + }); + await this.freeze(buildDir, key, { + key, + versions: resolvedVersions, + 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/fleet/src/cowClone.integration.test.ts b/packages/fleet/src/cowClone.integration.test.ts new file mode 100644 index 0000000000..a65f249c6e --- /dev/null +++ b/packages/fleet/src/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/fleet/src/cowClone.ts b/packages/fleet/src/cowClone.ts new file mode 100644 index 0000000000..694c5a7313 --- /dev/null +++ b/packages/fleet/src/cowClone.ts @@ -0,0 +1,70 @@ +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)); + }); + +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, + () => false, + ); + 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/fleet/src/index.ts b/packages/fleet/src/index.ts new file mode 100644 index 0000000000..9c056b0462 --- /dev/null +++ b/packages/fleet/src/index.ts @@ -0,0 +1,16 @@ +export const FLEET_PACKAGE = "@supabase/fleet"; + +export { cloneDir } from "./cowClone.ts"; +export type { EdgeProxyEvents, PodUpstream } from "./EdgeProxy.ts"; +export { EdgeProxy } from "./EdgeProxy.ts"; +export type { FleetHandle, FleetOptions, PodState, PodStatus } from "./Fleet.ts"; +export { createFleet } from "./Fleet.ts"; +export { IdleMonitor } from "./IdleMonitor.ts"; +export type { PodManifest } from "./PodManifest.ts"; +export { baseTemplateKey, templateKey } from "./PodManifest.ts"; +export { PodRegistry } from "./PodRegistry.ts"; +export type { PodPorts } from "./PortRegistry.ts"; +export { PortRegistry } from "./PortRegistry.ts"; +export type { CreatePodOptions } from "./Provisioner.ts"; +export { Provisioner } from "./Provisioner.ts"; +export { TemplateStore } from "./TemplateStore.ts"; diff --git a/packages/fleet/src/index.unit.test.ts b/packages/fleet/src/index.unit.test.ts new file mode 100644 index 0000000000..a9db3c5fd9 --- /dev/null +++ b/packages/fleet/src/index.unit.test.ts @@ -0,0 +1,8 @@ +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"); + }); +}); diff --git a/packages/fleet/src/podLock.ts b/packages/fleet/src/podLock.ts new file mode 100644 index 0000000000..6c2017b721 --- /dev/null +++ b/packages/fleet/src/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/fleet/src/podLock.unit.test.ts b/packages/fleet/src/podLock.unit.test.ts new file mode 100644 index 0000000000..f0df27ce65 --- /dev/null +++ b/packages/fleet/src/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/fleet/src/reapStalePostmaster.ts b/packages/fleet/src/reapStalePostmaster.ts new file mode 100644 index 0000000000..22e081b7db --- /dev/null +++ b/packages/fleet/src/reapStalePostmaster.ts @@ -0,0 +1,113 @@ +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 (in phase 1, `@supabase/stack`'s `createStack`, which — like + * process-compose's `detached: true` children — does not run postgres as a + * child of the fleet daemon's own process group). + * + * Phase 1 only ever runs postgres under a fleet pod (postgres-only ready + * gate; no HTTP edge / other services yet), so postmaster.pid alone is a + * complete picture of "is anything from this pod still alive" — no need to + * separately reap other service processes. + */ +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; + + signalPostmaster(pid, "SIGKILL"); + await waitUntilExited(pid, TERM_TIMEOUT_MS); +} diff --git a/packages/fleet/src/reapStalePostmaster.unit.test.ts b/packages/fleet/src/reapStalePostmaster.unit.test.ts new file mode 100644 index 0000000000..793377050c --- /dev/null +++ b/packages/fleet/src/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/fleet/tests/fleetDensity.e2e.test.ts b/packages/fleet/tests/fleetDensity.e2e.test.ts new file mode 100644 index 0000000000..e817b6999c --- /dev/null +++ b/packages/fleet/tests/fleetDensity.e2e.test.ts @@ -0,0 +1,39 @@ +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); +}); diff --git a/packages/fleet/tsconfig.json b/packages/fleet/tsconfig.json new file mode 100644 index 0000000000..ba396eb057 --- /dev/null +++ b/packages/fleet/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@tsconfig/bun/tsconfig.json" +} diff --git a/packages/fleet/vitest.config.ts b/packages/fleet/vitest.config.ts new file mode 100644 index 0000000000..6d1fd270fa --- /dev/null +++ b/packages/fleet/vitest.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + passWithNoTests: true, + coverage: { + enabled: false, + provider: "istanbul", + clean: false, + include: ["src/**/*.ts"], + reporter: ["text", "lcov"], + reportsDirectory: "coverage", + }, + projects: [ + { + test: { + name: "unit", + include: ["**/*.unit.test.ts"], + }, + }, + { + test: { + name: "integration", + include: ["**/*.integration.test.ts"], + }, + }, + { + test: { + name: "e2e", + include: ["**/*.e2e.test.ts"], + fileParallelism: false, + }, + }, + ], + }, +}); diff --git a/packages/process-compose/src/Orchestrator.ts b/packages/process-compose/src/Orchestrator.ts index ad24308ffa..11c8abe984 100644 --- a/packages/process-compose/src/Orchestrator.ts +++ b/packages/process-compose/src/Orchestrator.ts @@ -119,6 +119,7 @@ export class Orchestrator extends Context.Service< // 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 = ( @@ -530,10 +531,21 @@ export class Orchestrator extends Context.Service< const restartClosureFor = (name: string): ReadonlyArray => { const names = new Set([name]); + const visited = new Set([name]); + const shouldRestartDependent = (dependentName: string): boolean => { + const svc = services.get(dependentName); + if (svc === undefined) return false; + const status = SubscriptionRef.getUnsafe(svc.state).status; + if (status === "Pending") return launchedServices.has(dependentName); + return status !== "Stopped"; + }; 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); + if (shouldRestartDependent(dependent.name)) { + names.add(dependent.name); + } collectDependents(dependent.name); } }; @@ -601,6 +613,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 +626,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 +695,7 @@ export class Orchestrator extends Context.Service< }), ), ); + launchedServices.clear(); }), stopService: (name: string) => @@ -681,6 +709,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 +727,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)); } }), diff --git a/packages/process-compose/src/Orchestrator.unit.test.ts b/packages/process-compose/src/Orchestrator.unit.test.ts index 22a45f266b..38093bb8b0 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,112 @@ 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 traverses stopped one-shot helpers to restart 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; + }, {}); + expect(spawnCounts).toEqual({ + api: 2, + postgres: 2, + "postgres-init": 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", 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/src/ApiProxy.ts b/packages/stack/src/ApiProxy.ts index 12f258235e..a91945c7b6 100644 --- a/packages/stack/src/ApiProxy.ts +++ b/packages/stack/src/ApiProxy.ts @@ -9,6 +9,7 @@ import { HttpServerRequest, HttpServerResponse, } from "effect/unstable/http"; +import type { ServiceName } from "./versions.ts"; export interface ProxyConfig { readonly listenPort: number; @@ -22,10 +23,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; + /** + * When set (lazyServices mode), 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 +122,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; @@ -132,6 +142,19 @@ function makeProxyHandler( ) { return (req: HttpServerRequest.HttpServerRequest) => Effect.gen(function* () { + if (config.ensureService) { + for (const service of [opts.service, ...(opts.additionalServices ?? [])]) { + 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, + }); + } + } + } + let backendPath = opts.backendPath; if (backendPath === undefined) { @@ -222,6 +245,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 +254,7 @@ export class ApiProxy extends Context.Service< "/auth/v1/verify", makeProxyHandler(client, config, { backendPort: config.gotruePort, + service: "auth", stripPrefix: "/auth/v1", }), ), @@ -238,6 +263,7 @@ export class ApiProxy extends Context.Service< "/auth/v1/callback", makeProxyHandler(client, config, { backendPort: config.gotruePort, + service: "auth", stripPrefix: "/auth/v1", }), ), @@ -246,6 +272,7 @@ export class ApiProxy extends Context.Service< "/auth/v1/authorize", makeProxyHandler(client, config, { backendPort: config.gotruePort, + service: "auth", stripPrefix: "/auth/v1", }), ), @@ -254,6 +281,7 @@ export class ApiProxy extends Context.Service< "/auth/v1/*", makeProxyHandler(client, config, { backendPort: config.gotruePort, + service: "auth", stripPrefix: "/auth/v1", transformAuth: true, }), @@ -263,6 +291,7 @@ export class ApiProxy extends Context.Service< "/rest/v1/*", makeProxyHandler(client, config, { backendPort: config.postgrestPort, + service: "postgrest", stripPrefix: "/rest/v1", transformAuth: true, }), @@ -272,6 +301,7 @@ export class ApiProxy extends Context.Service< "/rest-admin/v1/*", makeProxyHandler(client, config, { backendPort: config.postgrestAdminPort, + service: "postgrest", stripPrefix: "/rest-admin/v1", }), ), @@ -280,6 +310,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 +321,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,6 +333,7 @@ export class ApiProxy extends Context.Service< "/realtime/v1/api/*", makeProxyHandler(client, config, { backendPort: config.realtimePort, + service: "realtime", stripPrefix: "/realtime/v1", transformAuth: true, }), @@ -310,6 +343,7 @@ export class ApiProxy extends Context.Service< "/realtime/v1/*", makeProxyHandler(client, config, { backendPort: config.realtimePort, + service: "realtime", stripPrefix: "/realtime/v1", }), ), @@ -318,7 +352,19 @@ export class ApiProxy extends Context.Service< "/storage/v1/s3/*", makeProxyHandler(client, config, { backendPort: config.storagePort, + service: "storage", + stripPrefix: "/storage/v1", + }), + ), + HttpRouter.route( + "*", + "/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 +372,7 @@ export class ApiProxy extends Context.Service< "/storage/v1/*", makeProxyHandler(client, config, { backendPort: config.storagePort, + service: "storage", stripPrefix: "/storage/v1", transformAuth: true, }), @@ -335,6 +382,7 @@ export class ApiProxy extends Context.Service< "/pg/*", makeProxyHandler(client, config, { backendPort: config.pgmetaPort, + service: "pgmeta", stripPrefix: "/pg", }), ), @@ -343,6 +391,7 @@ export class ApiProxy extends Context.Service< "/analytics/v1/*", makeProxyHandler(client, config, { backendPort: config.analyticsPort, + service: "analytics", stripPrefix: "/analytics/v1", }), ), @@ -351,6 +400,7 @@ export class ApiProxy extends Context.Service< "/pooler/v2/*", makeProxyHandler(client, config, { backendPort: config.poolerPort, + service: "pooler", stripPrefix: "/pooler", }), ), @@ -359,6 +409,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..76ccf6a9bb 100644 --- a/packages/stack/src/DaemonServer.integration.test.ts +++ b/packages/stack/src/DaemonServer.integration.test.ts @@ -4,6 +4,7 @@ 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, { @@ -76,6 +78,14 @@ function mockStack() { : Effect.sync(() => { serviceCalls.push(`restart:${name}`); }), + enableExtension: (name: string) => + name === "unknown" + ? Effect.fail(new ServiceNotFoundError({ name })) + : name === "bad-build" + ? Effect.fail(new StackBuildError({ detail: "cannot enable while starting" })) + : Effect.sync(() => { + serviceCalls.push(`enable-extension:${name}`); + }), reloadFunctions: () => Effect.sync(() => { serviceCalls.push("reload-functions"); @@ -96,7 +106,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 +134,9 @@ function mockStack() { get stopped() { return stopped; }, + get waitAllReadyCalls() { + return waitAllReadyCalls; + }, serviceCalls, }; } @@ -207,6 +223,15 @@ describe("DaemonServer", () => { expect(text).toContain("postgres"); }); + 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 // ------------------------------------------------------------------------- @@ -315,6 +340,13 @@ describe("DaemonServer", () => { expect(mock.serviceCalls).toContain("reload-edge-runtime"); }); + test("POST /extensions/:name/enable maps build errors to JSON 500", async () => { + const res = await fetch(`${url}/extensions/bad-build/enable`, { method: "POST" }); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("cannot enable while starting"); + }); + // ------------------------------------------------------------------------- // Error cases — service not found // ------------------------------------------------------------------------- diff --git a/packages/stack/src/DaemonServer.ts b/packages/stack/src/DaemonServer.ts index 4dca711302..4467613ba2 100644 --- a/packages/stack/src/DaemonServer.ts +++ b/packages/stack/src/DaemonServer.ts @@ -76,6 +76,28 @@ 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 })), + ), + ), + ), + // Start: begin service startup HttpRouter.route( "POST", @@ -166,6 +188,9 @@ export class DaemonServer extends Context.Service< Effect.catchTag("ServiceReadyError", (e) => Effect.succeed(HttpServerResponse.jsonUnsafe({ error: e.reason }, { status: 500 })), ), + Effect.catchTag("StackBuildError", (e) => + Effect.succeed(HttpServerResponse.jsonUnsafe({ error: e.detail }, { status: 500 })), + ), ), ), @@ -207,6 +232,31 @@ export class DaemonServer extends Context.Service< ), ), + HttpRouter.route( + "POST", + "/extensions/:name/enable", + Effect.gen(function* () { + const routeParams = yield* HttpRouter.params; + yield* stack.enableExtension(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 }, { status: 500 })), + ), + Effect.catchTag("StackBuildError", (e) => + Effect.succeed(HttpServerResponse.jsonUnsafe({ error: e.detail }, { status: 500 })), + ), + ), + ), + HttpRouter.route( "POST", "/functions/reload", diff --git a/packages/stack/src/RemoteStack.integration.test.ts b/packages/stack/src/RemoteStack.integration.test.ts index f7581f902d..e96b41dfc5 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}`); }), + enableExtension: (name: string) => + name === "unknown" + ? Effect.fail(new ServiceNotFoundError({ name })) + : Effect.sync(() => { + serviceCalls.push(`enable-extension:${name}`); + }), reloadFunctions: () => Effect.sync(() => { serviceCalls.push("reload-functions"); @@ -228,6 +234,13 @@ describe("RemoteStack integration", () => { ); if (res.status === 404) return yield* new ServiceNotFoundError({ name }); }), + enableExtension: (name: string) => + Effect.gen(function* () { + const res = yield* Effect.promise(() => + fetch(`${url}/extensions/${name}/enable`, { method: "POST" }), + ); + if (res.status === 404) return yield* new ServiceNotFoundError({ name }); + }), reloadFunctions: () => Effect.gen(function* () { yield* Effect.promise(() => fetch(`${url}/functions/reload`, { method: "POST" })); diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts index 00803d7444..28dddacaba 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,6 +35,7 @@ const StatusResponseSchema = Schema.Struct({ const ServiceErrorResponseSchema = Schema.Struct({ error: Schema.String, + service: Schema.optionalKey(Schema.String), }); const StatusServiceEventSchema = Schema.fromJsonString(StatusServiceSchema); @@ -278,6 +280,25 @@ export const RemoteStack = { }), ), + enableExtension: (name: string) => + withUnixHttpClient( + Effect.gen(function* () { + const response = yield* unixResponse(socketPath, `/extensions/${name}/enable`, { + 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* new ServiceReadyError({ name, reason: body.error }); + } + yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + }), + ), + reloadFunctions: (opts) => withUnixHttpClient( Effect.gen(function* () { @@ -402,35 +423,20 @@ export const RemoteStack = { 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); + if (body.service !== undefined) { + return yield* new ServiceReadyError({ + name: body.service, + reason: body.error, + }); + } + return yield* new StackBuildError({ detail: body.error }); + } + yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); }), ), diff --git a/packages/stack/src/Stack.ts b/packages/stack/src/Stack.ts index 27b53f7c14..ef0cdf72ad 100644 --- a/packages/stack/src/Stack.ts +++ b/packages/stack/src/Stack.ts @@ -67,6 +67,9 @@ export class Stack extends Context.Service< readonly restartService: ( name: string, ) => Effect.Effect; + readonly enableExtension: ( + name: string, + ) => Effect.Effect; readonly reloadFunctions: ( opts?: FunctionsConfig, ) => Effect.Effect; @@ -107,6 +110,7 @@ export class Stack extends Context.Service< startService: coordinator.startService, stopService: coordinator.stopService, restartService: coordinator.restartService, + enableExtension: coordinator.enableExtension, 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..6a397235fb 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, + lazyServices: false, 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..2f16d1f7c3 --- /dev/null +++ b/packages/stack/src/StackBuilder.provisioned.unit.test.ts @@ -0,0 +1,37 @@ +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({ + 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..34fdba9bf0 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,13 @@ 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 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 +53,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; @@ -150,6 +165,12 @@ export interface StackConfig { readonly mode?: "native" | "auto" | "docker"; readonly jwtSecret?: string; readonly port?: number; + /** + * When true, `start()` only eagerly starts `postgres` (and `postgres-init` when present); every + * other service is started on demand by the ApiProxy on the first request to its route, instead + * of starting everything up front. Default false: existing eager-start behavior is unchanged. + */ + readonly lazyServices?: boolean; readonly publishableKey?: string; readonly secretKey?: string; readonly functions?: FunctionsConfig | false; @@ -172,7 +193,10 @@ 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 +297,7 @@ export interface ResolvedStackConfig { readonly projectDir: string; readonly mode: "native" | "auto" | "docker"; readonly jwtSecret: string; + readonly lazyServices: boolean; readonly ports: AllocatedPorts; readonly apiPort: number; readonly dbPort: number; @@ -360,6 +385,10 @@ 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 => @@ -377,26 +406,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, }), ); } @@ -549,10 +569,21 @@ 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); + // Every service def stays enabled in the process-compose graph, including under + // lazyServices: process-compose's dependency graph excludes `enabled: false` defs + // entirely (they're never added as nodes), so a service disabled at build time can never + // later be started via `startService` — there's no supported "enable" path once the + // orchestrator is built. Instead, laziness is enforced one layer up: when + // config.lazyServices is on, StackLifecycleCoordinator.start() only eagerly starts + // postgres (and postgres-init); every other service is started on demand by the ApiProxy + // via `ensureService`, which calls the ordinary `startService` + `waitReady` coordinator + // methods against these same (enabled) defs. + const defs: Array = [ { ...(postgresResolution.type === "binary" @@ -560,13 +591,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,6 +616,7 @@ export class StackBuilder extends Context.Service< ...makePostgresInitService({ postgresDir: postgresResolution.path, dbPort: config.dbPort, + dbPassword: config.postgres.password, autoExposeNewTables: config.postgres.autoExposeNewTables, }), enabled: true, @@ -594,6 +629,7 @@ export class StackBuilder extends Context.Service< ? 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 +640,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, @@ -631,6 +668,7 @@ export class StackBuilder extends Context.Service< ? makeAuthServiceNative({ binPath: authResolution.path, dbPort: config.dbPort, + dbPassword: config.postgres.password, authPort: config.auth.port, siteUrl: config.auth.siteUrl, jwtSecret: config.jwtSecret, @@ -646,6 +684,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, @@ -719,6 +758,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, @@ -741,6 +781,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, @@ -784,6 +825,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, }), @@ -807,6 +849,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, [ @@ -844,6 +887,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, @@ -881,6 +925,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..cfda4eeece 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -43,6 +43,7 @@ const baseConfig: ResolvedStackConfig = { projectDir: "/tmp/supabase-project", mode: "auto", jwtSecret: testJwtSecret, + lazyServices: false, ports: basePorts, apiPort: 3000, dbPort: 5432, @@ -56,6 +57,7 @@ const baseConfig: ResolvedStackConfig = { port: 5432, dataDir: "/tmp/pg-data", version: DEFAULT_VERSIONS.postgres, + password: "postgres", autoExposeNewTables: true, }, postgrest: { @@ -298,6 +300,32 @@ describe("StackBuilder", () => { }).pipe(Effect.provide(layer)); }); + it.effect("lazyServices still builds every service enabled in the graph", () => { + // process-compose's dependency graph excludes `enabled: false` defs entirely (they're never + // added as nodes), so a service disabled at build time could never later be started via + // `startService`. lazyServices is enforced one layer up instead: StackLifecycleCoordinator + // only eagerly starts postgres/postgres-init, and the ApiProxy starts everything else on + // demand via the ordinary startService + waitReady coordinator methods, which require every + // def to remain a node in the graph. + const resolver = mockBinaryResolver(); + const layer = builderLayer(resolver); + + return Effect.gen(function* () { + const builder = yield* StackBuilder; + const preparation = yield* StackPreparation; + const { graph } = yield* prepareAndBuild(builder, preparation, { + ...baseConfig, + lazyServices: true, + }); + + 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.get("postgrest")?.enabled).toBe(true); + expect(byName.get("auth")?.enabled).toBe(true); + }).pipe(Effect.provide(layer)); + }); + it.effect("docker mode produces Docker service defs for all services", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); diff --git a/packages/stack/src/StackLifecycleCoordinator.ts b/packages/stack/src/StackLifecycleCoordinator.ts index 0a8ba5bee1..99348c9530 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,19 @@ 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 { planEnableExtension } from "./enableExtension.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 { installPodConfOverlay, readPreloadLibraries, writePreloadLibraries } from "./pgconf.ts"; import { StackMetadataPersistence } from "./StackMetadataPersistence.ts"; import { StackPreparation } from "./StackPreparation.ts"; import type { PreparedStackArtifacts } from "./StackPreparation.ts"; @@ -44,8 +48,45 @@ type LifecyclePhase = interface RuntimeState { readonly orchestrator: Orchestrator["Service"]; readonly cleanupTargets: CleanupTargets; + readonly graph: ResolvedGraph; } +// postgres/postgres-init are always nodes in the graph (StackBuilder never disables them), so +// ServiceNotFoundError can't actually occur when lazyServices eager-starts them — map it to +// StackBuildError only to satisfy start()'s declared error type. +const eagerStartService = ( + orchestrator: Orchestrator["Service"], + name: string, +): Effect.Effect => + orchestrator.startService(name).pipe( + Effect.catchTag( + "ServiceNotFoundError", + (error) => + new StackBuildError({ + detail: `lazyServices eager-start: unexpected missing service "${error.name}"`, + }), + ), + ); + +// Used both for the eager-start set in start() and for waitAllReady()'s started-service set +// under lazyServices. In both cases the name comes from either the graph's own startOrder or +// from a name the coordinator already validated via requireKnownService/startService, so +// ServiceNotFoundError can't actually occur — map it to StackBuildError only to satisfy the +// declared error types of the callers. +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 +112,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 +157,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 +198,9 @@ export class StackLifecycleCoordinator extends Context.Service< readonly restartService: ( name: string, ) => Effect.Effect; + readonly enableExtension: ( + name: string, + ) => Effect.Effect; readonly reloadFunctions: ( opts?: FunctionsConfig, ) => Effect.Effect; @@ -196,6 +252,22 @@ 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 enableExtensionLock = yield* Semaphore.make(1); + // Tracks every service that has actually been asked to start: the eager set from + // start() under lazyServices, plus anything started later via startService (the + // ApiProxy's ensureService on-demand path) or restartService. Under lazyServices, + // waitAllReady() only waits on this set, since never-started ServiceDefs never resolve + // their `healthy` deferred and never emit a Failed state either (see waitAllReady below). + 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,6 +292,17 @@ 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; @@ -398,6 +481,7 @@ export class StackLifecycleCoordinator extends Context.Service< return { orchestrator, cleanupTargets, + graph, } satisfies RuntimeState; }).pipe( Effect.tap((value) => @@ -517,13 +601,38 @@ 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* Ref.set(phaseRef, "starting"); yield* configureFunctions(config); - yield* runtime.orchestrator.start(); - yield* runtime.orchestrator.waitAllReady(); + if (config.lazyServices === true) { + // Only bring up postgres (and postgres-init, which depends on postgres and + // bootstraps the schema once). Every other service stays Pending until the + // ApiProxy starts it on demand via startService + waitReady (see ensureService in + // ApiProxy.ts / lazyServices.ts). + const eagerServices = runtime.graph.startOrder + .map((def) => def.name) + .filter((name) => name === "postgres" || name === "postgres-init"); + for (const name of eagerServices) { + yield* eagerStartService(runtime.orchestrator, name); + } + yield* markStarted(eagerServices); + yield* Effect.forEach( + eagerServices, + (name) => waitReadyKnownService(runtime.orchestrator, name), + { concurrency: "unbounded" }, + ); + } else { + yield* runtime.orchestrator.start(); + yield* markStarted(runtime.graph.startOrder.map((def) => def.name)); + yield* runtime.orchestrator.waitAllReady(); + } 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 +641,17 @@ 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* runtime.orchestrator.startService(name); + yield* markStarted([name]); yield* runtime.orchestrator.waitReady(name); }), stopService: (name) => @@ -547,19 +659,57 @@ 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); 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]); }), + enableExtension: (name) => + enableExtensionLock.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 enable extension "${name}" while the stack is starting. Wait for start() to finish and retry.`, + }), + ); + } + yield* Effect.promise(() => installPodConfOverlay(config.postgres.dataDir)); + const currentLibraries = yield* Effect.promise(() => + readPreloadLibraries(config.postgres.dataDir), + ); + const plan = planEnableExtension(name, currentLibraries); + if (plan.action === "none") { + return; + } + yield* Effect.promise(() => + writePreloadLibraries(config.postgres.dataDir, plan.libraries), + ); + 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* configureFunctions(configWithFunctionOptions(opts)); yield* runtime.orchestrator.restartService("edge-runtime"); + yield* markStarted(["edge-runtime"]); yield* runtime.orchestrator.waitReady("edge-runtime"); }), reloadEdgeRuntime: (opts) => @@ -590,6 +740,7 @@ export class StackLifecycleCoordinator extends Context.Service< ), ); yield* runtime.orchestrator.restartService("edge-runtime"); + yield* markStarted(["edge-runtime"]); yield* runtime.orchestrator.waitReady("edge-runtime"); }), getState: (name) => @@ -617,7 +768,26 @@ export class StackLifecycleCoordinator extends Context.Service< waitAllReady: () => Effect.gen(function* () { const runtime = yield* ensureRuntime; - yield* runtime.orchestrator.waitAllReady(); + if (config.lazyServices !== true) { + // Non-lazy: identical to pre-lazyServices behavior — every ServiceDef gets + // started by start(), so waiting on the full graph is correct and byte-identical + // to today. + yield* runtime.orchestrator.waitAllReady(); + return; + } + // 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..1320e51177 --- /dev/null +++ b/packages/stack/src/StackLifecycleCoordinator.unit.test.ts @@ -0,0 +1,450 @@ +import { 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, + lazyServices: false, + 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 enableExtension 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, spawner }; +} + +function makeLazyConfig(dataDir: string, postgrestPort: number): ResolvedStackConfig { + return { + ...makeConfig(dataDir), + lazyServices: true, + // 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 enableExtension", () => { + // Regression test: two concurrent enableExtension 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. + // enableExtension 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 enableExtension 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.enableExtension("pg_cron"), stack.enableExtension("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("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.enableExtension("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.enableExtension("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.enableExtension("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 lazyServices", () => { + // 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 + // enableExtension 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, 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(); + + // postgrest must not have been spawned by start() itself. + expect(spawner.spawned.some(isPostgrestPayload)).toBe(false); + + 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); + // 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. Under lazyServices, 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/UnixSocketSse.integration.test.ts b/packages/stack/src/UnixSocketSse.integration.test.ts index e6694814da..16875deb0e 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 })), + enableExtension: (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/createStack.ts b/packages/stack/src/createStack.ts index 0684ac867b..e28fc3b415 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, @@ -98,6 +99,7 @@ export interface StackHandle extends AsyncDisposable { startService(name: string): Promise; stopService(name: string): Promise; restartService(name: string): Promise; + enableExtension(name: string): Promise; reloadFunctions(opts?: FunctionsConfig): Promise; reloadEdgeRuntime(opts: EdgeRuntimeReloadConfig): Promise; ready(opts?: ReadyOptions): Promise; @@ -510,8 +512,8 @@ export async function resolveConfig( apiPort: config.port, dbPort: postgresInput.port, authPort: authInput?.port, - postgrestPort: undefined, - postgrestAdminPort: undefined, + postgrestPort: postgrestInput?.port, + postgrestAdminPort: postgrestInput?.adminPort, edgeRuntimePort: edgeRuntimeInput?.port, edgeRuntimeInspectorPort: edgeRuntimeInput?.inspectorPort, realtimePort: realtimeInput?.port, @@ -546,6 +548,7 @@ export async function resolveConfig( projectDir, mode: resolvedMode, jwtSecret, + lazyServices: config.lazyServices === true, ports, apiPort: ports.apiPort, dbPort: ports.dbPort, @@ -559,7 +562,10 @@ 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), @@ -728,6 +734,7 @@ export async function createStack( startService: (name) => run(localStack.startService(name)), stopService: (name) => run(localStack.stopService(name)), restartService: (name) => run(localStack.restartService(name)), + enableExtension: (name) => run(localStack.enableExtension(name)), reloadFunctions: (opts) => run(localStack.reloadFunctions(opts)), reloadEdgeRuntime: (opts) => run(localStack.reloadEdgeRuntime(opts)), ready: (opts) => { diff --git a/packages/stack/src/enableExtension.ts b/packages/stack/src/enableExtension.ts new file mode 100644 index 0000000000..fc0fec99b3 --- /dev/null +++ b/packages/stack/src/enableExtension.ts @@ -0,0 +1,14 @@ +import { PRELOAD_REQUIRED_EXTENSIONS } from "./micro.ts"; + +export type EnableExtensionPlan = + | { readonly action: "none" } + | { readonly action: "restart"; readonly libraries: ReadonlyArray }; + +export const planEnableExtension = ( + name: string, + currentLibraries: ReadonlyArray, +): EnableExtensionPlan => { + if (!PRELOAD_REQUIRED_EXTENSIONS.has(name)) return { action: "none" }; + if (currentLibraries.includes(name)) return { action: "none" }; + return { action: "restart", libraries: [...currentLibraries, name] }; +}; diff --git a/packages/stack/src/enableExtension.unit.test.ts b/packages/stack/src/enableExtension.unit.test.ts new file mode 100644 index 0000000000..f231dffa9b --- /dev/null +++ b/packages/stack/src/enableExtension.unit.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { planEnableExtension } from "./enableExtension.ts"; + +describe("planEnableExtension", () => { + it("no-ops for extensions that do not need preload", () => { + expect(planEnableExtension("pgvector", [])).toEqual({ action: "none" }); + }); + it("no-ops when already preloaded", () => { + expect(planEnableExtension("pg_cron", ["pg_cron"])).toEqual({ action: "none" }); + }); + it("appends and restarts otherwise", () => { + expect(planEnableExtension("pg_cron", ["pg_net"])).toEqual({ + action: "restart", + libraries: ["pg_net", "pg_cron"], + }); + }); +}); 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/index.ts b/packages/stack/src/index.ts index 087b40503e..a8e1aa217e 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -21,8 +21,20 @@ export type { } from "./StackBuilder.ts"; export type { ServiceName, VersionManifest } from "./versions.ts"; +export { DEFAULT_VERSIONS, fillServiceVersionManifest, 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, + installPodConfOverlay, + readPreloadLibraries, + writePreloadLibraries, +} from "./pgconf.ts"; +export { PRELOAD_REQUIRED_EXTENSIONS } from "./micro.ts"; diff --git a/packages/stack/src/layers.ts b/packages/stack/src/layers.ts index 7cc36fb504..899e77e39a 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,60 @@ 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. + * + * When `config.lazyServices` is on, the layer depends on `Stack` so it can wire + * `ProxyConfig.ensureService` to `Stack.startService` + `Stack.waitReady`, memoized so concurrent + * first requests to a route trigger a single start. Otherwise it's the plain config-only layer. + */ +const makeApiProxyLayer = ( + config: ResolvedStackConfig, +): Layer.Layer => { + if (!config.lazyServices) { + return ApiProxy.layer(baseProxyConfig(config)); + } + + return Layer.unwrap( + Effect.gen(function* () { + const stack = yield* Stack; + const ensureService = makeEnsureServiceMemo((name: ServiceName) => + Effect.runPromise( + Effect.gen(function* () { + yield* stack.startService(name); + yield* stack.waitReady(name); + }), + ), + ); + return ApiProxy.layer({ ...baseProxyConfig(config), ensureService }); + }), + ); +}; + /** * Build a foreground layer that runs the stack in-process. * @@ -47,24 +98,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 +132,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 +146,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/pgconf.ts b/packages/stack/src/pgconf.ts new file mode 100644 index 0000000000..a005bd5f49 --- /dev/null +++ b/packages/stack/src/pgconf.ts @@ -0,0 +1,73 @@ +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"); + +// 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 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 (!ACTIVE_INCLUDE_RE.test(main)) { + await writeFile(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 writeFile(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 writeFile(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 writeFile(podConf, `${line}\n`); + return; + } + if (PRELOAD_LIBRARIES_RE.test(existing)) { + const updated = existing.replace(PRELOAD_LIBRARIES_RE, line); + await writeFile(podConf, updated); + return; + } + const separator = existing.endsWith("\n") || existing === "" ? "" : "\n"; + await writeFile(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/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..803c496462 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, 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..576fa87765 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; @@ -54,15 +56,16 @@ params = %{ "default_parameter_status" => %{"server_version" => version}, "users" => [%{ "db_user" => "pgbouncer", - "db_password" => "postgres", + "db_password" => ${JSON.stringify(opts.dbPassword)}, "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 +79,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 +96,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..9ee855c67e 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,13 +26,19 @@ 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`; const migrationsDir = `${opts.postgresDir}/share/supabase-cli/migrations`; const psql = `${pgBinDir}/psql -h 127.0.0.1 -p ${opts.dbPort}`; - const psqlOpts = `-v ON_ERROR_STOP=1 --no-password --no-psqlrc`; + const psqlOpts = `-v ON_ERROR_STOP=1 --no-password --no-psqlrc -v pgpass="$TARGET_PGPASSWORD"`; const revokeStep = opts.autoExposeNewTables ? "" @@ -48,24 +55,38 @@ EOSQL // postgres-init time from ~5s to ~1s. const script = ` export PATH="${pgBinDir}:$PATH" -export PGPASSWORD=postgres +export TARGET_PGPASSWORD=${shellQuote(opts.dbPassword)} +export PGPASSWORD="$TARGET_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 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 $$ +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 +99,9 @@ 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' +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 +116,25 @@ 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' +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 +151,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 +160,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..f7dee6ad0a 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,6 +57,27 @@ 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) : []; @@ -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 }, diff --git a/packages/stack/src/services/postgrest.ts b/packages/stack/src/services/postgrest.ts index fffd349457..f89fe4814f 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", 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/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index 2db96bb9af..5c7f24f6d1 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, @@ -123,6 +164,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 +208,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, @@ -209,6 +252,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 +267,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 +276,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 +309,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 +340,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 +456,7 @@ describe("makePostgresInitService", () => { const def = makePostgresInitService({ postgresDir: POSTGRES_BIN_PATH, dbPort: DB_PORT, + dbPassword: DB_PASSWORD, autoExposeNewTables: true, }); @@ -426,6 +475,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 +486,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 +494,54 @@ 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; + + expect(script).toContain("export TARGET_PGPASSWORD='new-password'"); + expect(script).toContain('export PGPASSWORD="$DEFAULT_PGPASSWORD"'); + expect(script).toContain('-v pgpass="$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 +557,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 +571,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 +583,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 +679,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"], @@ -611,6 +710,7 @@ 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"], @@ -631,6 +731,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, @@ -659,5 +760,11 @@ describe("docker-backed auxiliary services", () => { expect(def.args).toContain(`PROXY_PORT_TRANSACTION=${poolerContainerPorts.transaction}`); expect(def.args).toContain(`54329:${poolerContainerPorts.admin}`); expect(def.args).toContain(`54330:${poolerContainerPorts.transaction}`); + const args = def.args ?? []; + const tenantScriptArg = args.find((arg) => arg.startsWith("SUPAVISOR_TENANT_SCRIPT=")); + expect(tenantScriptArg).toBeDefined(); + expect(tenantScriptArg).toContain("Supavisor.Tenants.create_tenant(params)"); + expect(tenantScriptArg).toContain("Supavisor.Tenants.update_tenant(tenant, params)"); + 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/tests/helpers/buildServices.ts b/packages/stack/tests/helpers/buildServices.ts new file mode 100644 index 0000000000..cba1d8f2de --- /dev/null +++ b/packages/stack/tests/helpers/buildServices.ts @@ -0,0 +1,164 @@ +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, + lazyServices: false, + 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 is 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, +): Promise> { + const config: ResolvedStackConfig = { + ...baseConfig, + 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/pnpm-lock.yaml b/pnpm-lock.yaml index 32618873a5..4ae8bcab92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -451,6 +451,40 @@ importers: specifier: 'catalog:' version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + packages/fleet: + dependencies: + '@supabase/stack': + specifier: workspace:* + version: link:../stack + devDependencies: + '@tsconfig/bun': + specifier: 'catalog:' + version: 1.0.10 + '@types/bun': + specifier: 'catalog:' + version: 1.3.14 + '@typescript/native-preview': + specifier: 'catalog:' + version: 7.0.0-dev.20260630.1 + '@vitest/coverage-istanbul': + specifier: 'catalog:' + version: 4.1.9(vitest@4.1.9) + knip: + specifier: 'catalog:' + version: 6.23.0 + oxfmt: + specifier: 'catalog:' + version: 0.57.0 + oxlint: + specifier: 'catalog:' + version: 1.73.0(oxlint-tsgolint@0.24.0) + oxlint-tsgolint: + specifier: 'catalog:' + version: 0.24.0 + vitest: + specifier: 'catalog:' + version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-istanbul@4.1.9)(vite@8.0.14(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + packages/process-compose: dependencies: '@effect/platform-bun':