diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index f24a2d1043..06fe9d8051 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -9,7 +9,7 @@ FROM supabase/studio:2026.08.17-sha-0c1da8f AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.3 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector -FROM supabase/supavisor:2.9.7 AS supavisor +FROM supabase/supavisor:2.9.10 AS supavisor FROM supabase/gotrue:v2.196.0 AS gotrue FROM supabase/realtime:v2.129.3 AS realtime FROM supabase/storage-api:v1.70.3 AS storage diff --git a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md index 3550e0148d..13053411a6 100644 --- a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md @@ -69,18 +69,19 @@ neither branch ever reaches the temp-login-role/Management-API path a passwordle ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | -| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | -| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | -| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | -| `SUPABASE_PROFILE` | profile name/path (env → `~/.supabase/profile` → `supabase`) | no | -| `SUPABASE_YES` | auto-confirm the native push step's prompts, read project-`.env`-aware like the standalone `db push` | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the legacy opt-out's catalog cache when `[experimental.pgdelta].enabled` is unset, read project-`.env`-aware | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy catalog warming, read project-`.env`-aware | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | legacy opt-out's edge-runtime image registry, read project-`.env`-aware | no | -| `PGDELTA_NPM_REGISTRY` | legacy opt-out's edge-runtime npm registry, read project-`.env`-aware | no | +| Variable | Purpose | Required? | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | +| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | +| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | +| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | +| `SUPABASE_PROFILE` | profile name/path (env → `~/.supabase/profile` → `supabase`) | no | +| `SUPABASE_YES` | auto-confirm the native push step's prompts, read project-`.env`-aware like the standalone `db push` | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the legacy opt-out's catalog cache when `[experimental.pgdelta].enabled` is unset, read project-`.env`-aware | no | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy catalog warming, read project-`.env`-aware | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | legacy opt-out's edge-runtime image registry, read project-`.env`-aware | no | +| `SUPABASE_USE_SLIM_IMAGES` | does not reach the legacy opt-out's edge-runtime image: it runs its script through an `sh -c` entrypoint the distroless slim build has no shell for, so it stays on docker.io | no | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out's edge-runtime npm registry, read project-`.env`-aware | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md index 8b57af8184..e1f50c1673 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -104,6 +104,7 @@ of this command's own target resolve, ahead of the differ container. | `PGDELTA_NPM_REGISTRY` | legacy opt-out's scoped npm registry | no | | `SUPABASE_SSL_DEBUG` | migra SSL debug logging | no | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the differ's / shadow's image registry (shell **or** project `.env`, applied for the run via `legacyApplyProjectEnv`, matching `db push`/`db pull`/`db dump`) | no | +| `SUPABASE_USE_SLIM_IMAGES` | resolves the shadow/pg-meta images from the slim `ghcr.io/supabase/cli/` builds (`true`/`1` enable); the differ image has no slim build and stays on docker.io | no | `SUPABASE_DB_SHADOW_PORT`/`SUPABASE_NETWORK_ID`/`--network-id`/`SUPABASE_PROJECT_ID`/ `SUPABASE_DB_HEALTH_TIMEOUT` all apply to `--use-pgadmin` too — its shadow is provisioned diff --git a/apps/cli/src/legacy/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/dump/SIDE_EFFECTS.md index ec2351d0e7..2db8cda63f 100644 --- a/apps/cli/src/legacy/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/dump/SIDE_EFFECTS.md @@ -31,13 +31,14 @@ script run inside the local Postgres image to stdout or `--file`. ## Environment Variables -| Variable | Purpose | -| ----------------------------------------------------------------------------- | --------------------------------------------- | -| `SUPABASE_DB_PASSWORD` (`DB_PASSWORD` viper key; `--password`/`-p` overrides) | remote DB password | -| `SUPABASE_ACCESS_TOKEN` | `--linked` auth | -| `BITBUCKET_CLONE_DIR` | (no-op for dump — no `--security-opt` is set) | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | rewrite the pg image registry | -| `DOCKER_HOST` | docker daemon endpoint | +| Variable | Purpose | +| ----------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| `SUPABASE_DB_PASSWORD` (`DB_PASSWORD` viper key; `--password`/`-p` overrides) | remote DB password | +| `SUPABASE_ACCESS_TOKEN` | `--linked` auth | +| `BITBUCKET_CLONE_DIR` | (no-op for dump — no `--security-opt` is set) | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | rewrite the pg image registry | +| `SUPABASE_USE_SLIM_IMAGES` | resolve the pg image from the slim `ghcr.io/supabase/cli` builds | +| `DOCKER_HOST` | docker daemon endpoint | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md index 3c13990982..f1d0bb7e6a 100644 --- a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md @@ -120,6 +120,7 @@ baseline, so it is never cached. | `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | shadow container-config overrides, same as `db start`/`db reset` | no | | `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`); ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | | `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | +| `SUPABASE_USE_SLIM_IMAGES` | resolves the shadow Postgres and `pg_dump` container images from the slim `ghcr.io/supabase/cli` builds instead of the docker.io Dockerfile pins (`true`/`1` enable) | no | | `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | | `SUPABASE_SHADOW_CACHE` | shadow baseline cache; opt-in (`1`/`true`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | | `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta diff engine | no | diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index fdf1627eb5..59d79e811e 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -54,6 +54,7 @@ before migrations unless `--skip-vault` is set. | `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the migrations-catalog cache when `[experimental.pgdelta].enabled` is unset | no (project `.env` or shell) | | `SUPABASE_USE_PG_DELTA_NEXT` | selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache warmup (unset/unrecognized defaults to the next engine, which skips it); shell presence wins over project `.env`, even an empty shell value | no (project `.env` or shell) | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the pg-delta edge-runtime image registry for the cache export | no (project `.env` or shell) | +| `SUPABASE_USE_SLIM_IMAGES` | does not reach the pg-delta edge-runtime image: the cache export delivers its script through an `sh -c` entrypoint the distroless slim build has no shell for, so that container stays on docker.io | no (ambient shell only) | | `PGDELTA_NPM_REGISTRY` | overrides the pg-delta edge-runtime npm registry (`.npmrc` + `NPM_CONFIG_REGISTRY` forward) for the cache export | no (project `.env` or shell) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index 5b2dfb6b5e..e31e5f99eb 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -140,6 +140,7 @@ the whole reset** (not just "skip buckets"). | `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the post-reset migrations-catalog cache (see Files Written) when `[experimental.pgdelta].enabled` is unset — distinct from `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` above, which switches the reset's own apply branch instead | no (project `.env` or shell) | | `SUPABASE_USE_PG_DELTA_NEXT` | selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache (unset/unrecognized defaults to the next engine, which skips it); shell presence wins over project `.env`, even an empty shell value | no (project `.env` or shell) | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the pg-delta edge-runtime image registry for the migrations-catalog cache export (scoped for the whole run via `legacyApplyProjectEnv`, matching `db push`) | no (project `.env` or shell) | +| `SUPABASE_USE_SLIM_IMAGES` | does not reach the pg-delta edge-runtime image: the migrations-catalog cache export delivers its script through an `sh -c` entrypoint the distroless slim build has no shell for, so that container stays on docker.io | no (ambient shell only) | | `PGDELTA_NPM_REGISTRY` | overrides the pg-delta edge-runtime npm registry (`.npmrc` + `NPM_CONFIG_REGISTRY` forward) for the migrations-catalog cache export (scoped for the whole run via `legacyApplyProjectEnv`, matching `db push`) | no (project `.env` or shell) | | `SUPABASE_DB_PORT` / `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | local-path container-recreate config overrides, same as `db start` | no | | `SUPABASE_NETWORK_ID` (`--network-id`) | forces the recreated container/network onto an existing Docker network | no | diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index 6d827f6882..3ab17d480f 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -48,17 +48,18 @@ formatting without disabling safe compaction. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------- | ---------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no | -| `DB_PASSWORD` | password for `--linked` / `--db-url` | no | -| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the legacy opt-out's shadow baseline cache | no | -| `SUPABASE_SHADOW_CACHE` | shadow baseline cache for the legacy opt-out's catalog-miss shadows; opt-in (`1`/`true`) | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | -| `PGDELTA_NPM_REGISTRY` | legacy opt-out's private npm registry | no | -| `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | -| `SUPABASE_SERVICES_HOSTNAME` | local DB host for `--local` | no | -| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | +| Variable | Purpose | Required? | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no | +| `DB_PASSWORD` | password for `--linked` / `--db-url` | no | +| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the legacy opt-out's shadow baseline cache | no | +| `SUPABASE_SHADOW_CACHE` | shadow baseline cache for the legacy opt-out's catalog-miss shadows; opt-in (`1`/`true`) | no | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out's private npm registry | no | +| `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | +| `SUPABASE_SERVICES_HOSTNAME` | local DB host for `--local` | no | +| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | +| `SUPABASE_USE_SLIM_IMAGES` | resolves the legacy opt-out's shadow platform-baseline container from the slim `ghcr.io/supabase/cli` builds instead of the docker.io Dockerfile pins (`true`/`1` enable) | no | ## Exit Codes @@ -109,3 +110,11 @@ always go to stderr, in every `--output-format`. On success: in-process (create the shadow container, wait for health, run the auth/storage/realtime one-shot migrate jobs, export the catalog, remove the container) using the same primitives as `db diff` and `db pull`. +- **Stale local-container guard.** `--local`/smart-mode's Local target inspects + the running local `db` container's actual image and compares it against the + currently-configured/resolved one before reading from it; a family mismatch + (a docker.io container when a slim `ghcr.io/supabase/cli` image is now + expected, or vice versa, e.g. after toggling `SUPABASE_USE_SLIM_IMAGES` + between runs without restarting) is treated as stale even when the tags + otherwise match, and fails with a suggestion to reset the local database + (`supabase stop --all --no-backup`, then `supabase start`). diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 6159e934bf..883432c025 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -52,15 +52,16 @@ disabling safe compaction. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | -| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | -| `PGDELTA_NPM_REGISTRY` | legacy opt-out's private npm registry | no | -| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | -| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; opt-in (`1`/`true`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | -| `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | -| `SUPABASE_SERVICES_HOSTNAME` | local DB host for the bootstrap generate | no | -| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | +| Variable | Purpose | Required? | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out's private npm registry | no | +| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | +| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; opt-in (`1`/`true`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | +| `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | +| `SUPABASE_SERVICES_HOSTNAME` | local DB host for the bootstrap generate | no | +| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | +| `SUPABASE_USE_SLIM_IMAGES` | resolves both scoped shadow Postgres containers (bundled engine) / the shadow's platform-baseline container (legacy opt-out) from the slim `ghcr.io/supabase/cli` builds instead of the docker.io Dockerfile pins (`true`/`1` enable) | no | ## Exit Codes @@ -137,6 +138,13 @@ existing SQL or creates an export manifest. shadows. Under the legacy opt-out, both catalog shadows are provisioned in-process using the same primitives as `db diff`; catalog export, declarative apply, and diff run through the edge-runtime pg-delta scripts. +- **Stale local-container guard.** Before diffing against the running local `db` + target, the running container's actual image is inspected and compared + against the currently-configured/resolved one; a family mismatch (a docker.io + container when a slim `ghcr.io/supabase/cli` image is now expected, or vice + versa, e.g. after toggling `SUPABASE_USE_SLIM_IMAGES` between runs without + restarting) is treated as stale even when the tags otherwise match, and fails + with a suggestion to `supabase stop --all --no-backup` then `supabase start`. ### Shadow baseline cache (`SUPABASE_SHADOW_CACHE`, default OFF) diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts index 3132d010a5..0c1a590849 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { afterEach, vi } from "vitest"; import { mockLegacyCliSettings, @@ -115,12 +116,17 @@ const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { function setup( workdir: string, - opts: { readonly failCreate?: boolean; readonly dbInspectFailsWith?: string } = {}, + opts: { + readonly failCreate?: boolean; + readonly dbInspectFailsWith?: string; + readonly dbInspectImage?: string; + } = {}, ) { const out = mockOutput(); const shadowSpawner = mockLegacyShadowContainerCliSpawner({ failCreate: opts.failCreate, dbInspectFailsWith: opts.dbInspectFailsWith, + dbInspectImage: opts.dbInspectImage, }); const dbConnection = fakeShadowDbConnection(); const docker = fakeShadowSetupDocker(); @@ -272,3 +278,40 @@ describe("legacyDeclarativeSeamLayer.ensureLocalDatabaseStarted", () => { }, ); }); + +describe("legacyDeclarativeSeamLayer.ensureLocalPostgresImageCurrent", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.effect( + "flags a running docker.io container as stale against a slim-flagged expectation, even on a matching tag", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-seam-")); + const { layer } = setup(dir, { dbInspectImage: "supabase/postgres:17.6.1.165" }); + return Effect.gen(function* () { + const seam = yield* LegacyDeclarativeSeam; + const exit = yield* seam.ensureLocalPostgresImageCurrent().pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const error = failError(exit); + expect(error).toBeInstanceOf(LegacyDeclarativeShadowDbError); + expect((error as LegacyDeclarativeShadowDbError).message).toContain( + "local Postgres container image is stale", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("passes when the running container matches the expected image's family and tag", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-seam-")); + const { layer } = setup(dir, { dbInspectImage: "supabase/postgres:17.6.1.165" }); + return Effect.gen(function* () { + const seam = yield* LegacyDeclarativeSeam; + const exit = yield* seam.ensureLocalPostgresImageCurrent().pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index f4be9856ba..9cd91a5c73 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -7,6 +7,7 @@ import { legacyResolveDbImage } from "../../../shared/legacy-db-image.ts"; import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; import { legacyIsDockerDaemonUnreachable } from "../../../shared/legacy-docker-suggest.ts"; +import { isSlimImageRef } from "../../../../shared/services/slim-images.ts"; import { legacyIsLocalDbRunning } from "../../../shared/db-bootstrap/local-db-running.ts"; import { legacyStartLocalDatabase } from "../../../shared/db-bootstrap/start-local-database.ts"; import { @@ -262,7 +263,14 @@ export const legacyDeclarativeSeamLayer = Layer.effect( const expected = legacyGetRegistryImageUrl(image).trim(); const actualTag = dockerImageTag(actual); const expectedTag = dockerImageTag(expected); - if (actualTag.length === 0 || expectedTag.length === 0 || actualTag === expectedTag) { + // Slim refs never go through a registry mirror, so a family mismatch + // (e.g. a docker.io container satisfying a ghcr.io/supabase/cli + // expectation) is stale even when the tags happen to match. + const familyMismatch = isSlimImageRef(expected) !== isSlimImageRef(actual); + if ( + !familyMismatch && + (actualTag.length === 0 || expectedTag.length === 0 || actualTag === expectedTag) + ) { return; } return yield* Effect.fail( diff --git a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md index 88457f78aa..34cbf9e502 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -31,6 +31,16 @@ composition reuses too — see that command's `SIDE_EFFECTS.md`): `cron.launch_active_jobs = off` appended to `postgresql.conf` — applies regardless of `db.major_version`. The backup file itself is bind-mounted `:ro` at `/etc/backup.sql` (host path resolved against the CALLER's cwd when relative). + `SUPABASE_USE_SLIM_IMAGES` changes the container's shape (never its volume, published + port, healthcheck, or labels): the image's own entrypoint is kept instead of a `sh -c` + heredoc script, `[db.settings]` travels as trailing `-c key=value` argv rather than a + `postgresql.conf` append, and `/etc/postgresql.schema.sql` plus the pgsodium root key are + `docker cp`'d in before start (the slim image's bundled `migrate.sh` runs that schema file + once, at initdb, exactly like the docker.io image does). Slim schema SQL also re-promotes + `postgres` to SUPERUSER and `CREATE EXTENSION vector` after the image's bundled demote + migration, so storage-api vector-bucket migrations can run as that role. `--from-backup` combined with a + resolved slim image is refused here instead — the restore entrypoint has no slim + equivalent. 6. Wait for the container to become healthy (`db.health_timeout`, default `2m`). A timeout fails the command UNLESS `--from-backup` is set, in which case it is swallowed (a large restore can exceed the timeout) — the container-logs dump to stderr still happens @@ -106,26 +116,27 @@ native container command in this codebase — never `supabase-go`. ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_PROJECT_ID` | overrides the local container id | no | -| `SUPABASE_DB_PORT` | overrides `db.port` (the published host port) | no | -| `SUPABASE_DB_MAJOR_VERSION` | overrides `db.major_version` (image selection, schema branch) | no | -| `SUPABASE_DB_HEALTH_TIMEOUT` | overrides `db.health_timeout` | no | -| `SUPABASE_DB_SETTINGS_*` | overrides individual `[db.settings]` fields | no | -| `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` | overrides `experimental.orioledb_version` (image + env) | no | -| `SUPABASE_EXPERIMENTAL_S3_{HOST,REGION,ACCESS_KEY,SECRET_KEY}` | OrioleDB S3 env overrides | no | -| `SUPABASE_REALTIME_ENABLED` | gates the fresh-volume realtime migrate job | no | -| `SUPABASE_REALTIME_IP_VERSION` / `_MAX_HEADER_LENGTH` | realtime migrate job env overrides | no | -| `SUPABASE_STORAGE_ENABLED` | gates the fresh-volume storage migrate job | no | -| `SUPABASE_STORAGE_FILE_SIZE_LIMIT` | storage migrate job env override | no | -| `SUPABASE_AUTH_ENABLED` | gates the fresh-volume auth migrate job | no | -| `SUPABASE_AUTH_EXTERNAL_URL` / `SUPABASE_AUTH_SITE_URL` | auth migrate job env overrides | no | -| `SUPABASE_AUTH_JWT_EXPIRY` | Postgres's `JWT_EXP` env / signing | no | -| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache warmup (unset/unrecognized defaults to the next engine, which skips it) | no | -| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file, installed into the process environment before any Docker work) to pick the Docker daemon this whole command talks to | no | +| Variable | Purpose | Required? | +| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_PROJECT_ID` | overrides the local container id | no | +| `SUPABASE_USE_SLIM_IMAGES` | resolves every service image from the slim `ghcr.io/supabase/cli/` builds instead of the docker.io Dockerfile pins (`true`/`1` enable); the pg 13/14/15 fallbacks, OrioleDB images, and the pg-delta catalog-warmup container (its script needs an `sh -c` entrypoint the distroless build has no shell for) stay on docker.io | no | +| `SUPABASE_DB_PORT` | overrides `db.port` (the published host port) | no | +| `SUPABASE_DB_MAJOR_VERSION` | overrides `db.major_version` (image selection, schema branch) | no | +| `SUPABASE_DB_HEALTH_TIMEOUT` | overrides `db.health_timeout` | no | +| `SUPABASE_DB_SETTINGS_*` | overrides individual `[db.settings]` fields | no | +| `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` | overrides `experimental.orioledb_version` (image + env) | no | +| `SUPABASE_EXPERIMENTAL_S3_{HOST,REGION,ACCESS_KEY,SECRET_KEY}` | OrioleDB S3 env overrides | no | +| `SUPABASE_REALTIME_ENABLED` | gates the fresh-volume realtime migrate job | no | +| `SUPABASE_REALTIME_IP_VERSION` / `_MAX_HEADER_LENGTH` | realtime migrate job env overrides | no | +| `SUPABASE_STORAGE_ENABLED` | gates the fresh-volume storage migrate job | no | +| `SUPABASE_STORAGE_FILE_SIZE_LIMIT` | storage migrate job env override | no | +| `SUPABASE_AUTH_ENABLED` | gates the fresh-volume auth migrate job | no | +| `SUPABASE_AUTH_EXTERNAL_URL` / `SUPABASE_AUTH_SITE_URL` | auth migrate job env overrides | no | +| `SUPABASE_AUTH_JWT_EXPIRY` | Postgres's `JWT_EXP` env / signing | no | +| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | +| `SUPABASE_USE_PG_DELTA_NEXT` | selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache warmup (unset/unrecognized defaults to the next engine, which skips it) | no | +| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file, installed into the process environment before any Docker work) to pick the Docker daemon this whole command talks to | no | `--network-id` (a global CLI flag, not an environment variable — `shared/legacy/global-flags.ts`) forces every created container/network onto that Docker network instead of the generated diff --git a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts index 37c0f1413a..bfcc2cf597 100644 --- a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -21,6 +21,10 @@ import { legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { + LegacySlimImagesBackupUnsupportedError, + LegacySlimImageVolumeInaccessibleError, +} from "../../../shared/db-bootstrap/start-database.ts"; import { LegacyDebugFlag, LegacyExperimentalFlag, @@ -229,6 +233,27 @@ function runningCheckFailsRoute( }; } +/** Overrides the default route's answer to the slim-image reused-volume access probe + * (`docker run --rm --entrypoint /usr/bin/sh -v :/probe -c "test -r + * /probe/PG_VERSION && test -w /probe"`) so a test can force it accessible/inaccessible + * without a real container. */ +function slimVolumeProbeRoute( + base: (args: ReadonlyArray) => RouteResult, + accessible: boolean, +): (args: ReadonlyArray) => RouteResult { + return (args) => { + if (args[0] === "run" && args.includes("--entrypoint")) { + return { exitCode: accessible ? 0 : 1 }; + } + return base(args); + }; +} + +/** Whether the slim-image reused-volume access probe (see {@link slimVolumeProbeRoute}) ran. */ +function slimVolumeProbeWasRun(spawned: ReadonlyArray): boolean { + return spawned.some((s) => s.args[0] === "run" && s.args.includes("--entrypoint")); +} + const alwaysReadyHttpClientLayer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -389,6 +414,7 @@ const currentBranchPath = (workdir: string) => describe("legacy db start", () => { afterEach(() => { delete process.env["SUPABASE_NETWORK_ID"]; + vi.unstubAllEnvs(); }); it.live("reports an already-running database without starting a container", () => { @@ -652,6 +678,86 @@ describe("legacy db start", () => { }, ); + // The restore path is entirely a docker.io entrypoint feature, so the slim + // image is refused rather than silently starting an empty cluster. + it.live( + "--from-backup under SUPABASE_USE_SLIM_IMAGES is refused before any container is created", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(flags("/abs/host/backup.sql")).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacySlimImagesBackupUnsupportedError); + if (error instanceof LegacySlimImagesBackupUnsupportedError) { + expect(error.message).toBe( + "--from-backup is not supported with SUPABASE_USE_SLIM_IMAGES", + ); + expect(error.suggestion).toContain("Unset SUPABASE_USE_SLIM_IMAGES"); + } + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + + // A reused volume's PGDATA ownership is a property of whichever image initialized it: a + // docker.io-initialized volume's `700`-mode dirs (owned by that image's `postgres` uid) block + // the slim image's non-root `65532` user, crash-looping until the health check times out with + // no useful message. `legacyIsVolumeAccessibleToImage` probes for this before any container is + // created. + it.live( + "SUPABASE_USE_SLIM_IMAGES against an existing volume inaccessible to the slim image fails before any container is created", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const { layer, child } = setup({ route: slimVolumeProbeRoute(defaultRoute(), false) }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacySlimImageVolumeInaccessibleError); + if (error instanceof LegacySlimImageVolumeInaccessibleError) { + expect(error.message).toContain("not readable and writable"); + expect(error.suggestion).toContain("supabase stop --no-backup"); + expect(error.suggestion).toContain("SUPABASE_USE_SLIM_IMAGES"); + } + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + + it.live( + "SUPABASE_USE_SLIM_IMAGES against an existing volume accessible to the slim image proceeds to create the container", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const { layer, child, out } = setup({ route: slimVolumeProbeRoute(defaultRoute(), true) }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(slimVolumeProbeWasRun(child.spawned)).toBe(true); + expect(createArgs(child.spawned)).not.toBeUndefined(); + expect(out.stderrText).toContain("Starting database from backup...\n"); + }); + }, + ); + + it.live( + "no SUPABASE_USE_SLIM_IMAGES: an existing volume never runs the slim-image readability probe", + () => { + const { layer, child } = setup(); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(slimVolumeProbeWasRun(child.spawned)).toBe(false); + }); + }, + ); + it.live( '--from-backup against an existing volume fails with "backup volume already exists" and rolls back without creating a container', () => { diff --git a/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md index cdbc9b26a6..8ac96cd816 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md @@ -53,6 +53,7 @@ Docker bundling may pull or run the configured edge-runtime image and uses the | `SUPABASE_PROJECT_ID` | optional project ref fallback; also read from project dotenv now (previously ambient-shell-only) | no | | `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the Functions bundler image registry; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no | +| `SUPABASE_USE_SLIM_IMAGES` | resolves the Functions bundler image from the slim `ghcr.io/supabase/cli/edge-runtime` build (`true`/`1` enable); ambient shell only, unlike the neighboring registry override | no | | `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | | `BITBUCKET_CLONE_DIR` | when set, skips creating the named Deno-cache volume and omits its bind mount from the bundler `docker run` (Bitbucket's restricted Docker environment rejects both); a project-dotenv-only value is installed into `process.env` by config loading | no | | `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which bundler image tag to use) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | diff --git a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md index 73edf752af..b2f4e0cc95 100644 --- a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md @@ -67,6 +67,7 @@ to stderr in machine-output modes (CLI-1546). | `SUPABASE_ENV` | Docker-unbundle path: selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | | `BITBUCKET_CLONE_DIR` | Docker-unbundle path: when set, skips creating the named Deno-cache volume and omits its bind mount from the `docker run` command (Bitbucket's restricted Docker environment rejects both) | no | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the registry the edge-runtime unbundle image is pulled from (`legacyGetRegistryImageUrl`); read from the ambient shell **or** project dotenv (Docker-unbundle path); unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL — also consumed on the `--use-api` invocation even though it never pulls an image | no (defaults to `public.ecr.aws`) | +| `SUPABASE_USE_SLIM_IMAGES` | Docker-unbundle path: resolves the edge-runtime unbundle image from the slim `ghcr.io/supabase/cli/edge-runtime` build (`true`/`1` enable); `deno_version = 1` keeps its docker.io image | no | | `SUPABASE_NETWORK_ID` | Docker-unbundle path: overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | | `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | Docker-unbundle path: overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | diff --git a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md index c681f36b73..0b4d7c18f8 100644 --- a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md @@ -54,6 +54,7 @@ back to local keys. No scheme/host validation is performed on the discovered URL | `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | | env vars referenced by `supabase/config.toml` | config interpolation; the full ambient `process.env` is layered under the project `.env*` files and passed to config loading | no | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the edge-runtime Docker registry mirror; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no (defaults to `public.ecr.aws`) | +| `SUPABASE_USE_SLIM_IMAGES` | resolves the edge-runtime image from the slim `ghcr.io/supabase/cli/edge-runtime` build (`true`/`1` enable); `deno_version = 1` keeps its docker.io image | no | | `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | | `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | | `BITBUCKET_CLONE_DIR` | when set, skips creating the named Deno-cache volume and omits its bind mount from the edge-runtime `docker create` (Bitbucket's restricted Docker environment rejects both); a project-dotenv-only value is installed into `process.env` by config loading | no | @@ -113,6 +114,6 @@ Long-running raw log / error events only; there is no terminal `result` event on - Before each container (re)start, resolves the edge-runtime image through the same registry-candidate pull-with-retry every native `functions` Docker path uses: `docker image inspect ` (ECR, then GHCR, then Docker Hub) to check the local cache, then `docker pull ` with 2 retries (4s/8s backoff) on a miss, after `assertLocalDbRunning` — resolving it earlier would hijack the down-daemon error message that DB-inspect step is responsible for producing. - Runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`, same one `start`/`stop`/`status` use) on every startup/restart, before `assertLocalDbRunning` — an invalid config now fails `serve` up front even for fields this command never otherwise reads (e.g. a bad `db.major_version` or malformed auth hook). - A container crash terminates the command with a non-zero exit; only a watched-file change restarts the container — a crashed container is never auto-restarted. -- The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. It is delivered into the created (not yet started) container as a `docker cp` stdin tar archive at `/root/index.ts` — never a single-file host bind mount, which materializes as an empty directory on daemons that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, podman machines) and breaks bring-up with edge-runtime's "failed to determine entrypoint" (supabase/cli#6254). Only this bootstrap template is daemon-independent: user function sources, import maps, static files, and the multiline-env script directory (present only when an env value contains a newline) still arrive by host bind mounts, so they require a daemon that can see the project directory. +- The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. It is delivered into the created (not yet started) container as a `docker cp` stdin tar archive at `/root/index.ts` (docker.io) or `/tmp/index.ts` with `--main-service=/tmp` on slim images (uid 65532 cannot read `/root`) — never a single-file host bind mount, which materializes as an empty directory on daemons that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, podman machines) and breaks bring-up with edge-runtime's "failed to determine entrypoint" (supabase/cli#6254). Only this bootstrap template is daemon-independent: user function sources, import maps, static files, and the multiline-env script directory (present only when an env value contains a newline) still arrive by host bind mounts, so they require a daemon that can see the project directory. - Existing local values declared under an import map's `scopes` are explicit read-only Docker mounts and may resolve outside the nearest Git root; each distinct out-of-root host path prints one `WARN` during bring-up, deduplicated across Functions sharing an import map. Such out-of-root mounts are excluded from the file-watch set per Function, so a scope target contributes no watch root of its own and cannot enlarge or destabilise the watcher; a path that another Function reaches through its ordinary binds is still watched. Other file-valued binds are watched through their immediate parent non-recursively, while directory binds remain recursive. Missing targets retain serve's existing skip behavior. - **Intentional divergence from Go — spec-strict import-map key matching (CLI-2179, ruled 2026-08-12):** bind mounts are computed by the functions import scanner (`walkImportPaths`/`substituteImportMapValue`, shared with `functions deploy` and `start`'s Edge Runtime bring-up), which matches import-map keys per the import-maps spec Deno/edge-runtime implement — exact match, or prefix match only for a `/`-suffixed key — instead of Go's any-key `strings.HasPrefix` (`pkg/function/deno.go:150-155`). Bind mounts may shrink vs the Go CLI for maps that relied on bare-key prefix matching; an unwalkable target (`ENOTDIR` — a value routed through a file) is skipped with a `WARN`. diff --git a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md index 6441e6b534..b753ab500d 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -58,19 +58,20 @@ timeout. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | local Docker container and network project ID | no (falls back to the workdir name) | -| `SUPABASE_DB_PORT` | local database probe port | no (defaults to `54322`) | -| `SUPABASE_DB_MAJOR_VERSION` | local PostgreSQL major version | no (defaults to `17`) | -| `SUPABASE_API_SCHEMAS` | local schemas used when `--schema` is omitted | no (defaults to `public,graphql_public`) | -| `SUPABASE_ENV` | selects nested dotenv files for local generation | no (defaults to `development`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_DB_PASSWORD` | database password for `--local` and the `--linked` workdir project | no (defaults to `postgres`; **ignored** for ad-hoc `--project-id`, which always mints a temporary login role) | -| `SUPABASE_SERVICES_HOSTNAME` | host used for the local TLS probe | no (defaults to `127.0.0.1`) | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | pg-meta image registry override (`docker.io` → Docker Hub; any other value → that registry) | no (defaults to the ECR registry) | -| `SUPABASE_CA_SKIP_VERIFY` | when `true`, prints a TLS-verification-disabled warning to stderr | no | +| Variable | Purpose | Required? | +| ---------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | local Docker container and network project ID | no (falls back to the workdir name) | +| `SUPABASE_DB_PORT` | local database probe port | no (defaults to `54322`) | +| `SUPABASE_DB_MAJOR_VERSION` | local PostgreSQL major version | no (defaults to `17`) | +| `SUPABASE_API_SCHEMAS` | local schemas used when `--schema` is omitted | no (defaults to `public,graphql_public`) | +| `SUPABASE_ENV` | selects nested dotenv files for local generation | no (defaults to `development`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_DB_PASSWORD` | database password for `--local` and the `--linked` workdir project | no (defaults to `postgres`; **ignored** for ad-hoc `--project-id`, which always mints a temporary login role) | +| `SUPABASE_SERVICES_HOSTNAME` | host used for the local TLS probe | no (defaults to `127.0.0.1`) | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | pg-meta image registry override (`docker.io` → Docker Hub; any other value → that registry) | no (defaults to the ECR registry) | +| `SUPABASE_USE_SLIM_IMAGES` | resolves the pg-meta image from the slim `ghcr.io/supabase/cli/pgmeta` build (`true`/`1` enable) | no | +| `SUPABASE_CA_SKIP_VERIFY` | when `true`, prints a TLS-verification-disabled warning to stderr | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md index cced4a7655..89548d5f5c 100644 --- a/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md @@ -65,7 +65,7 @@ migration-history table to match. `SUPABASE_YES`, `DB_PASSWORD`, `SUPABASE_ACCESS_TOKEN`, `SUPABASE_SERVICES_HOSTNAME`, `DOCKER_HOST`/`DOCKER_CONTEXT`/`DOCKER_CONFIG`, `SUPABASE_NETWORK_ID`, -`SUPABASE_INTERNAL_IMAGE_REGISTRY`, `SUPABASE_PROJECT_ID`, `SUPABASE_DEBUG`, +`SUPABASE_INTERNAL_IMAGE_REGISTRY`, `SUPABASE_USE_SLIM_IMAGES`, `SUPABASE_PROJECT_ID`, `SUPABASE_DEBUG`, `SUPABASE_EXPERIMENTAL`. ## Exit Codes diff --git a/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md index ea470c0dd1..bbaef851a5 100644 --- a/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/services/SIDE_EFFECTS.md @@ -40,10 +40,11 @@ Tenant calls send `apikey: ` and additionally ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------------------- | ----------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for Management API linked-version checks | no (falls back to keyring, then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| Variable | Purpose | Required? | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for Management API linked-version checks | no (falls back to keyring, then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_USE_SLIM_IMAGES` | rewrites the local `LOCAL` column's image references to the slim `ghcr.io/supabase/cli/` builds (`true`/`1` enable) | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 57ab6738e6..1ad3c6284c 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -32,7 +32,8 @@ after Postgres's own health check passes, before "Starting containers..." prints before any other service starts. Opens a direct `LegacyDbConnection` session to the host-facing Postgres address (PG<=14: execs schema/globals/API-privileges SQL over that session; PG>=15: runs three one-shot `LegacyDockerRun` jobs instead, gated independently on -`realtime.enabled`/`storage.enabled`/`auth.enabled`). Also upserts `[db.vault]` secrets and +`realtime.enabled`/`storage.enabled`/`auth.enabled`; slim refs skip the Realtime and Storage +jobs and run Auth as `migrate`). Also upserts `[db.vault]` secrets and seeds `supabase/roles.sql`: the `Seeding globals from roles.sql...` stderr line always prints first, whether or not the file exists — a missing file is silently tolerated (no SQL runs), any other read/exec error still fails the run. Finally runs every pending migration + @@ -103,6 +104,28 @@ Kong's `custom_nginx.template`, Vector's `vector.yaml`, and Postgres's own boots script (`postgresql.conf`-equivalent setup) are all rendered in memory and injected directly into each container's entrypoint (a `sh -c '... heredoc ...'` command) — never written to the host filesystem, since none of them carries secret content. +Exception: with `SUPABASE_USE_SLIM_IMAGES` enabled the Postgres container keeps the +slim image's own entrypoint — settings travel as `-c` argv and the bootstrap schema is +delivered via `docker cp` alongside the root key instead (see `db start`'s +SIDE_EFFECTS.md, which documents the slim container shape both commands share). +The same flag also keeps Vector's image entrypoint (`vector --config /etc/vector/vector.yaml`) +and copies `vector.yaml` via `secretFiles` instead of a `sh` heredoc, because the slim Vector +image has no shell-based entrypoint override. Distroless slim services with no `/bin/sh` +(auth, storage, studio, pg-meta) omit Docker healthchecks — `docker create --health-cmd` is +always `CMD-SHELL` — and `legacyCheckContainerReady` treats `Running` as ready; slim Storage +is the exception, gated instead on a `HEAD /storage/v1/status` probe through Kong (the same +gateway shape PostgREST and Edge Runtime use) so bucket seeding below cannot race its +startup. That probe is skipped when Kong itself is `--exclude`d, since nothing then routes +to Storage. Realtime and +analytics keep a busybox `wget --spider` probe; slim Edge Runtime is started without +`--entrypoint sh` (the wrapped binary has no shell) and the main-service template is +copied to `/tmp/index.ts` (`--main-service=/tmp`) because the image runs as uid 65532 +and cannot read `/root`. Slim Storage mounts its named volume at `/home/nonroot` +(owned by uid 65532 in the image) instead of `/mnt`, which Docker would create +root-owned. Slim Postgres's `/etc/postgresql.schema.sql` postinit also +runs `ALTER ROLE postgres WITH SUPERUSER` plus `CREATE EXTENSION vector` as +`supabase_admin` after bundled migrations demote `postgres` — storage-api's +vector-bucket migrations create that extension as the `postgres` role. Kong's `kong.yml`/TLS cert/TLS key, Postgres's `pgsodium_root.key`, and Supavisor's `pooler_tenant.exs` DO carry secret content (a service-role-key-derived bearer/query key, TLS private key material, and the DB password respectively). Since @@ -137,7 +160,8 @@ recreates its own subdirectory fresh on every call (self-healing), so a shrinking env set never leaves stale files behind. The bootstrap `index.ts` template carries no secret content and, as of supabase/cli#6254, never touches host disk at all: it is streamed via `docker cp` straight into the created (not yet started) Edge Runtime -container — a single-file host bind mount materializes as an empty directory on daemons +container at `/root/index.ts` (docker.io) or `/tmp/index.ts` (slim, uid 65532 cannot +read `/root`) — a single-file host bind mount materializes as an empty directory on daemons that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, podman machines), which broke `start` with edge-runtime's "failed to determine entrypoint". Only the bootstrap template is daemon-independent: user function @@ -159,19 +183,20 @@ not implemented. ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_*` (any dotted config field) | Generic Viper-style `AutomaticEnv` override of any `config.toml` field (e.g. `SUPABASE_AUTH_ENABLED`, `SUPABASE_API_PORT`) | no | -| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | Fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` (see "Fresh-volume DB setup" above) | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | Enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | Selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache warmup (unset/unrecognized defaults to the next engine, which skips it) | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | Overrides the image registry used to resolve every service's image | no | -| `SUPABASE_PROJECT_ID` | Overrides the resolved local project id (env → config.toml → workdir basename) | no | -| `SUPABASE_WORKDIR` | Resolves `LegacyCliSettings.workdir` | no | -| `BITBUCKET_CLONE_DIR` | When non-empty, drops named volumes and `--security-opt` from every container create | no | -| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file) to discover the Docker daemon this whole command talks to; `DOCKER_HOST` is also re-derived and set on Vector's container env so it can reach the host's Docker socket for log collection | no | -| `KONG_NGINX_WORKER_PROCESSES` | Read (ambient shell or project dotenv) into Kong's own container env (defaults to `"1"` when unset) | no | -| `HTTP_PROXY` / `http_proxy` / `HTTPS_PROXY` / `https_proxy` / `NO_PROXY` / `no_proxy` | Bun proxy settings. After project dotenv and container creation, `start` appends `localhost,127.0.0.1,[::1]` to the effective no-proxy value before local Kong probes and seeding; it never changes project/container env and ends with this CLI process. | no | +| Variable | Purpose | Required? | +| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_*` (any dotted config field) | Generic Viper-style `AutomaticEnv` override of any `config.toml` field (e.g. `SUPABASE_AUTH_ENABLED`, `SUPABASE_API_PORT`) | no | +| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | Fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` (see "Fresh-volume DB setup" above) | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | Enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | +| `SUPABASE_USE_PG_DELTA_NEXT` | Selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache warmup (unset/unrecognized defaults to the next engine, which skips it) | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | Overrides the image registry used to resolve every service's image | no | +| `SUPABASE_USE_SLIM_IMAGES` | Resolves every local service image from the slim `ghcr.io/supabase/cli/` builds instead of the docker.io images pinned in the embedded Dockerfile (`true`/`1` enable); services with no slim build (Kong, the pg 13/14/15 fallbacks, OrioleDB, `deno_version = 1`) stay on docker.io, as does the pg-delta catalog-warmup container (it runs its script through an `sh -c` entrypoint the distroless build has no shell for), and `SUPABASE_INTERNAL_IMAGE_REGISTRY` does not apply to slim refs | no | +| `SUPABASE_PROJECT_ID` | Overrides the resolved local project id (env → config.toml → workdir basename) | no | +| `SUPABASE_WORKDIR` | Resolves `LegacyCliSettings.workdir` | no | +| `BITBUCKET_CLONE_DIR` | When non-empty, drops named volumes and `--security-opt` from every container create | no | +| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file) to discover the Docker daemon this whole command talks to; `DOCKER_HOST` is also re-derived and set on Vector's container env so it can reach the host's Docker socket for log collection | no | +| `KONG_NGINX_WORKER_PROCESSES` | Read (ambient shell or project dotenv) into Kong's own container env (defaults to `"1"` when unset) | no | +| `HTTP_PROXY` / `http_proxy` / `HTTPS_PROXY` / `https_proxy` / `NO_PROXY` / `no_proxy` | Bun proxy settings. After project dotenv and container creation, `start` appends `localhost,127.0.0.1,[::1]` to the effective no-proxy value before local Kong probes and seeding; it never changes project/container env and ends with this CLI process. | no | `docker`/`podman` must be resolvable on `PATH` — same fallback behavior as `stop`/`status`. diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts index 913582d235..2b95632647 100644 --- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts @@ -5,10 +5,11 @@ import { describe, expect, it } from "@effect/vitest"; import { edgeRuntimeNofileUlimit } from "@supabase/stack/effect"; import { Deferred, Effect, Exit, Sink, Stream } from "effect"; import { type ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { beforeEach } from "vitest"; +import { afterEach, beforeEach, vi } from "vitest"; import { useLegacyTempWorkdir } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { SlimEdgeRuntimeMultilineSecretError } from "../../../../shared/functions/serve.errors.ts"; import { legacyStartEdgeRuntimeContainer, type LegacyEdgeRuntimeBringUpInput, @@ -130,6 +131,10 @@ describe("legacyStartEdgeRuntimeContainer", () => { mkdirSync(join(tempWorkdir.current, "supabase", "functions"), { recursive: true }); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it.effect( "sends the real internal db url (db container name, port 5432, config.db.password) — NOT functions serve's `db`-alias default", () => @@ -346,6 +351,70 @@ describe("legacyStartEdgeRuntimeContainer", () => { }), ); + it.effect( + "slim edge-runtime: copies the main service to /tmp (uid 65532 cannot read /root) and drops --entrypoint sh", + () => + Effect.gen(function* () { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const mock = mockDockerSpawner(); + const out = mockOutput(); + const input = { + ...baseInput(tempWorkdir.current), + image: "ghcr.io/supabase/cli/edge-runtime:v1.74.2", + }; + + yield* legacyStartEdgeRuntimeContainer(input).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const createArgs = mock.runCall!.args; + expect(createArgs).not.toContain("--entrypoint"); + expect(createArgs).toContain("start"); + expect(createArgs).toContain("--main-service=/tmp"); + expect(createArgs).not.toContain("--main-service=/root"); + + const cp = mock.calls.find((call) => call.args[0] === "cp"); + expect(cp?.args).toEqual(["cp", "-", "supabase_edge_runtime_proj:/"]); + const stdin = cp?.stdin; + expect(Stream.isStream(stdin)).toBe(true); + if (!Stream.isStream(stdin)) return yield* Effect.die("docker cp stdin was not a stream"); + const chunks = yield* Stream.runCollect(stdin); + expect(chunks).toHaveLength(1); + const archiveBytes = chunks[0]; + if (!(archiveBytes instanceof Uint8Array)) { + return yield* Effect.die("docker cp stdin did not contain archive bytes"); + } + const files = yield* Effect.promise(() => new Bun.Archive(archiveBytes).files()); + expect([...files.keys()]).toEqual(["tmp/index.ts"]); + }), + ); + + it.effect( + "slim edge-runtime: refuses a multiline secret it has no shell to source, before creating anything", + () => + Effect.gen(function* () { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const mock = mockDockerSpawner(); + const out = mockOutput(); + const input = { + ...baseInput(tempWorkdir.current), + image: "ghcr.io/supabase/cli/edge-runtime:v1.74.2", + edgeRuntimeSecrets: { MULTI_LINE_KEY: "-----BEGIN KEY-----\nsecret\n-----END KEY-----" }, + }; + + const error = yield* legacyStartEdgeRuntimeContainer(input).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(SlimEdgeRuntimeMultilineSecretError); + expect(String(error)).toContain("cannot source multiline function secrets"); + expect(mock.runCall).toBeUndefined(); + }), + ); + it.effect( "surfaces docker's own stderr verbatim and never reaches cp/start when docker create fails", () => diff --git a/apps/cli/src/legacy/commands/start/services/gotrue.service.ts b/apps/cli/src/legacy/commands/start/services/gotrue.service.ts index 976eef8a7f..8fb978d503 100644 --- a/apps/cli/src/legacy/commands/start/services/gotrue.service.ts +++ b/apps/cli/src/legacy/commands/start/services/gotrue.service.ts @@ -54,6 +54,7 @@ import { import { LEGACY_DEFAULT_SIGNING_KEY } from "../../../shared/legacy-go-jwt.ts"; import type { LegacyResolvedAuthEmail } from "../../../shared/legacy-local-config-values.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { legacyUsesSlimRuntime } from "../../../shared/db-bootstrap/slim-runtime.ts"; import { legacyStartInternalDbPassword, legacyStartInternalDbUrl, @@ -645,6 +646,7 @@ export function legacyBuildGotrueContainerSpec( const dbHost = legacyServiceContainerName("db", input.projectId); const dbPassword = legacyStartInternalDbPassword(input.dbUrl); const env = legacyBuildGotrueEnv({ ...input.env, dbHost, dbPassword }); + const slim = legacyUsesSlimRuntime(input.image); return { image: input.image, @@ -652,19 +654,25 @@ export function legacyBuildGotrueContainerSpec( env, binds: [], exposedPorts: [{ containerPort: LEGACY_GOTRUE_PORT }], - healthcheck: { - test: [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - `http://127.0.0.1:${LEGACY_GOTRUE_PORT}/health`, - ], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - }, + // Distroless slim auth has no wget/curl/sh. Omitting the Docker healthcheck + // makes `legacyCheckContainerReady` treat `Running` as ready (same as PostgREST). + ...(slim + ? {} + : { + healthcheck: { + test: [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + `http://127.0.0.1:${LEGACY_GOTRUE_PORT}/health`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + }), restartPolicy: "unless-stopped", networkId: input.networkId, networkAliases: [LEGACY_GOTRUE_CONTAINER_SUFFIX], diff --git a/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts index 65450f6a0a..191d936c38 100644 --- a/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { legacyBuildGotrueContainerSpec, @@ -11,6 +11,10 @@ import { type LegacyGotrueWebauthnInput, } from "./gotrue.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + // Every field not asserted by a specific subtest below reflects the // default config's own values. const baseEnvInput: LegacyBuildGotrueEnvInput = { @@ -708,4 +712,18 @@ describe("legacyBuildGotrueContainerSpec", () => { "postgresql://supabase_auth_admin:secret@supabase_db_proj:5432/postgres", ); }); + + test("omits the wget healthcheck only when the slim flag is on", () => { + const input = { + image: "ghcr.io/supabase/cli/auth:v2.196.0", + projectId: "proj", + networkId: "supabase_network_proj", + dbUrl: "postgresql://postgres:secret@127.0.0.1:54322/postgres", + env: baseEnvInput, + }; + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(legacyBuildGotrueContainerSpec(input).healthcheck?.test[1]).toBe("wget"); + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + expect(legacyBuildGotrueContainerSpec(input).healthcheck).toBeUndefined(); + }); }); diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.ts index d51ad00fb1..e96cd19fe5 100644 --- a/apps/cli/src/legacy/commands/start/services/logflare.service.ts +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.ts @@ -19,6 +19,10 @@ import { join } from "node:path"; import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { + legacySlimBusyboxWgetHealthcheck, + legacyUsesSlimRuntime, +} from "../../../shared/db-bootstrap/slim-runtime.ts"; /** The Logflare network alias — also this service's `containerSuffix` in `LEGACY_SERVICE_CATALOG`. */ const LEGACY_LOGFLARE_CONTAINER_SUFFIX = "analytics"; @@ -146,23 +150,41 @@ export function legacyBuildLogflareContainerSpec( env.POSTGRES_BACKEND_SCHEMA = "_analytics"; } + const slim = legacyUsesSlimRuntime(input.image); + return { image: input.image, containerName: legacyServiceContainerName(LEGACY_LOGFLARE_CONTAINER_SUFFIX, input.projectId), hostname: "127.0.0.1", env, - entrypoint: "sh", - cmd: ["-c", LEGACY_LOGFLARE_ENTRYPOINT_SCRIPT], + ...(slim + ? {} + : { + entrypoint: "sh", + cmd: ["-c", LEGACY_LOGFLARE_ENTRYPOINT_SCRIPT], + }), binds, exposedPorts: [{ containerPort: "4000" }], ports: [{ hostPort: String(input.port), containerPort: "4000" }], - healthcheck: { - test: ["CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "http://127.0.0.1:4000/health"], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - startPeriodSeconds: 10, - }, + healthcheck: slim + ? legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:4000/health", { + startPeriodSeconds: 10, + }) + : { + test: [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "http://127.0.0.1:4000/health", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + startPeriodSeconds: 10, + }, restartPolicy: "unless-stopped", networkId: input.networkId, networkAliases: [LEGACY_LOGFLARE_CONTAINER_SUFFIX], diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts index 33b5db08cb..c9dd72a480 100644 --- a/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts @@ -1,12 +1,16 @@ import { join } from "node:path"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { legacyBuildLogflareContainerSpec, type LegacyLogflareContainerSpecInput, } from "./logflare.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + const base: LegacyLogflareContainerSpecInput = { image: "supabase/logflare:1.0.0", projectId: "proj", @@ -117,4 +121,23 @@ describe("legacyBuildLogflareContainerSpec", () => { }); expect(spec.binds).toEqual([`${join("/workdir", "")}:/opt/app/rel/logflare/bin/gcloud.json`]); }); + + test("keeps the image entrypoint and uses busybox wget on a slim analytics image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildLogflareContainerSpec({ + ...base, + image: "ghcr.io/supabase/cli/analytics:v1.50.4", + }); + expect(spec.entrypoint).toBeUndefined(); + expect(spec.cmd).toBeUndefined(); + expect(spec.healthcheck?.test).toEqual([ + "CMD", + "/bin/busybox", + "wget", + "-q", + "--spider", + "http://127.0.0.1:4000/health", + ]); + expect(spec.healthcheck?.startPeriodSeconds).toBe(10); + }); }); diff --git a/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts b/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts index 0d1d14e27c..d90de80da9 100644 --- a/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts +++ b/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts @@ -16,6 +16,7 @@ */ import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { legacyUsesSlimRuntime } from "../../../shared/db-bootstrap/slim-runtime.ts"; /** The hardcoded pg-meta listen port (`PG_META_PORT=8080`) — never configurable. */ const PG_META_PORT = 8080; @@ -64,15 +65,21 @@ export function legacyBuildPgMetaContainerSpec( PG_META_DB_PASSWORD: input.dbPassword, }, binds: [], - healthcheck: { - test: [ - "CMD-SHELL", - `node --eval="fetch('http://127.0.0.1:${PG_META_PORT}/health').then((r) => {if (!r.ok) throw new Error(r.status)})"`, - ], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - }, + // Distroless slim pg-meta has no /bin/sh; Docker CLI healthchecks are always + // CMD-SHELL. Omitting makes `legacyCheckContainerReady` treat Running as ready. + ...(legacyUsesSlimRuntime(input.image) + ? {} + : { + healthcheck: { + test: [ + "CMD-SHELL", + `node --eval="fetch('http://127.0.0.1:${PG_META_PORT}/health').then((r) => {if (!r.ok) throw new Error(r.status)})"`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + }), restartPolicy: "unless-stopped", networkId: input.networkId, networkAliases: PG_META_NETWORK_ALIASES, diff --git a/apps/cli/src/legacy/commands/start/services/pg-meta.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/pg-meta.service.unit.test.ts index a63048a97f..85221dbdfc 100644 --- a/apps/cli/src/legacy/commands/start/services/pg-meta.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/pg-meta.service.unit.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { legacyBuildPgMetaContainerSpec } from "./pg-meta.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("legacyBuildPgMetaContainerSpec", () => { test("assembles the full container spec from resolved inputs", () => { const spec = legacyBuildPgMetaContainerSpec({ @@ -60,4 +64,19 @@ describe("legacyBuildPgMetaContainerSpec", () => { expect(spec.env["PG_META_DB_PASSWORD"]).toBe("hunter2"); expect(spec.healthcheck?.test[1]).toContain("127.0.0.1:8080"); }); + + test("omits the Docker healthcheck on a slim distroless pg-meta image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildPgMetaContainerSpec({ + image: "ghcr.io/supabase/cli/pgmeta:v0.98.0", + containerName: "supabase_pg_meta_proj", + dbHost: "supabase_db_proj", + dbPort: 5432, + dbUser: "postgres", + dbPassword: "postgres", + dbName: "postgres", + networkId: "supabase_network_proj", + }); + expect(spec.healthcheck).toBeUndefined(); + }); }); diff --git a/apps/cli/src/legacy/commands/start/services/realtime.service.ts b/apps/cli/src/legacy/commands/start/services/realtime.service.ts index 895c82f9d9..127d7405e5 100644 --- a/apps/cli/src/legacy/commands/start/services/realtime.service.ts +++ b/apps/cli/src/legacy/commands/start/services/realtime.service.ts @@ -17,6 +17,10 @@ import { legacyBuildRealtimeEnv, } from "../../../shared/db-bootstrap/realtime-env.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { + legacySlimBusyboxWgetHealthcheck, + legacyUsesSlimRuntime, +} from "../../../shared/db-bootstrap/slim-runtime.ts"; import { legacyStartInternalDbPassword } from "../../../shared/db-bootstrap/internal-db-connection.ts"; export interface LegacyRealtimeContainerSpecInput { @@ -57,24 +61,28 @@ export function legacyBuildRealtimeContainerSpec( env, binds: [], exposedPorts: [{ containerPort: "4000" }], - healthcheck: { - // Podman splits command by spaces unless quoted, but curl's header can't be - // quoted, hence this exec-form `test` array. - test: [ - "CMD", - "curl", - "-sSfL", - "--head", - "-o", - "/dev/null", - "-H", - `Host:${LEGACY_REALTIME_TENANT_ID}`, - "http://127.0.0.1:4000/api/ping", - ], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - }, + healthcheck: legacyUsesSlimRuntime(input.image) + ? legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:4000/api/ping", { + header: `Host:${LEGACY_REALTIME_TENANT_ID}`, + }) + : { + // Podman splits command by spaces unless quoted, but curl's header can't be + // quoted, hence this exec-form `test` array. + test: [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "-H", + `Host:${LEGACY_REALTIME_TENANT_ID}`, + "http://127.0.0.1:4000/api/ping", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, restartPolicy: "unless-stopped", networkId: input.networkId, // Network aliases: `realtime` plus the tenant id. diff --git a/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts index 3aca580e8b..5c2b987ea8 100644 --- a/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts @@ -1,10 +1,14 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { legacyBuildRealtimeContainerSpec, type LegacyRealtimeContainerSpecInput, } from "./realtime.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("legacyBuildRealtimeContainerSpec", () => { const input: LegacyRealtimeContainerSpecInput = { projectId: "proj", @@ -65,4 +69,22 @@ describe("legacyBuildRealtimeContainerSpec", () => { }); expect(spec.env["DB_PASSWORD"]).toBe("another-secret"); }); + + test("uses busybox wget for the healthcheck on a slim realtime image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildRealtimeContainerSpec({ + ...input, + image: "ghcr.io/supabase/cli/realtime:v2.129.3", + }); + expect(spec.healthcheck?.test).toEqual([ + "CMD", + "/bin/busybox", + "wget", + "-q", + "--spider", + "--header", + "Host:realtime-dev", + "http://127.0.0.1:4000/api/ping", + ]); + }); }); diff --git a/apps/cli/src/legacy/commands/start/services/storage.service.ts b/apps/cli/src/legacy/commands/start/services/storage.service.ts index e38aa9d1f2..3153c30f0b 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.ts @@ -37,8 +37,9 @@ import type { CliConfig } from "@supabase/config"; import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; -import { ramInBytes } from "../../../shared/legacy-size-units.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { legacyUsesSlimRuntime } from "../../../shared/db-bootstrap/slim-runtime.ts"; +import { ramInBytes } from "../../../shared/legacy-size-units.ts"; import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; import { legacyStartInternalDbUrl, @@ -47,6 +48,14 @@ import { /** Both the container's `FILE_STORAGE_BACKEND_PATH` and its named-volume mount target. */ const LEGACY_STORAGE_DOCKER_PATH = "/mnt"; +/** + * Distroless slim storage runs as uid 65532. `/mnt` does not exist in the + * image, so a named volume mounted there is created root-owned and + * `mkdir /mnt/stub` fails with EACCES. `/home/nonroot` is owned by that user; + * Docker copies the image directory into an empty named volume, so the + * tenant dir is writable. + */ +const LEGACY_STORAGE_SLIM_DOCKER_PATH = "/home/nonroot"; export interface LegacyStorageVectorEnvInput { /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ @@ -196,52 +205,67 @@ export interface LegacyStorageContainerSpecInput { /** * Builds the `docker create` spec for the Storage container. `binds` mounts - * the container's own named volume at `/mnt` — no `ports`/`exposedPorts`, - * Storage is reached only via its Docker network alias. + * the container's own named volume at `/mnt` (docker.io) or `/home/nonroot` + * (slim, uid 65532) — no `ports`/`exposedPorts`, Storage is reached only via + * its Docker network alias. */ export function legacyBuildStorageContainerSpec( input: LegacyStorageContainerSpecInput, ): LegacyStartContainerSpec { const containerName = legacyServiceContainerName("storage", input.projectId); - const env = legacyBuildStorageEnv({ - targetMigration: input.targetMigration, - anonKey: input.anonKey, - serviceRoleKey: input.serviceRoleKey, - jwtSecret: input.jwtSecret, - jwks: input.jwks, - dbHost: legacyServiceContainerName("db", input.projectId), - dbPassword: legacyStartInternalDbPassword(input.dbUrl), - fileSizeLimit: input.fileSizeLimit, - s3Region: input.s3Region, - s3AccessKeyId: input.s3AccessKeyId, - s3SecretAccessKey: input.s3SecretAccessKey, - imageTransformationEnabled: input.imageTransformationEnabled, - imgproxyHost: legacyServiceContainerName("imgproxy", input.projectId), - s3ProtocolEnabled: input.s3ProtocolEnabled, - vectorBucketsEnabled: input.vectorBucketsEnabled, - projectEnvValues: input.projectEnvValues, - }); + const slim = legacyUsesSlimRuntime(input.image); + const storagePath = slim ? LEGACY_STORAGE_SLIM_DOCKER_PATH : LEGACY_STORAGE_DOCKER_PATH; + const env = { + ...legacyBuildStorageEnv({ + targetMigration: input.targetMigration, + anonKey: input.anonKey, + serviceRoleKey: input.serviceRoleKey, + jwtSecret: input.jwtSecret, + jwks: input.jwks, + dbHost: legacyServiceContainerName("db", input.projectId), + dbPassword: legacyStartInternalDbPassword(input.dbUrl), + fileSizeLimit: input.fileSizeLimit, + s3Region: input.s3Region, + s3AccessKeyId: input.s3AccessKeyId, + s3SecretAccessKey: input.s3SecretAccessKey, + imageTransformationEnabled: input.imageTransformationEnabled, + imgproxyHost: legacyServiceContainerName("imgproxy", input.projectId), + s3ProtocolEnabled: input.s3ProtocolEnabled, + vectorBucketsEnabled: input.vectorBucketsEnabled, + projectEnvValues: input.projectEnvValues, + }), + FILE_STORAGE_BACKEND_PATH: storagePath, + }; return { image: input.image, containerName, env, - binds: [`${containerName}:${LEGACY_STORAGE_DOCKER_PATH}`], - healthcheck: { - // "For some reason, localhost resolves to IPv6 address on GitPod which breaks - // healthcheck." — IPv4 loopback pinned. - test: [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - "http://127.0.0.1:5000/status", - ], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - }, + binds: [`${containerName}:${storagePath}`], + // Distroless slim storage has no /bin/sh (nor wget) and Docker CLI + // healthchecks are always CMD-SHELL, so there is no probe to declare here. + // `start` gates readiness on Kong's `/storage/v1/status` instead — see + // `health-check.ts` — because `legacyCheckContainerReady` would otherwise + // accept a merely-Running container. + ...(slim + ? {} + : { + healthcheck: { + // "For some reason, localhost resolves to IPv6 address on GitPod which breaks + // healthcheck." — IPv4 loopback pinned. + test: [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:5000/status", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + }), restartPolicy: "unless-stopped", networkId: input.networkId, // The Storage network alias. diff --git a/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts index 69d8456c98..7c96760858 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { legacyAppendStorageVectorEnv, @@ -8,6 +8,10 @@ import { type LegacyStorageEnvInput, } from "./storage.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + const baseEnvInput: LegacyStorageEnvInput = { targetMigration: "", anonKey: "anon-key", @@ -253,4 +257,23 @@ describe("legacyBuildStorageContainerSpec", () => { "postgresql://postgres:postgres@supabase_db_proj:5432/postgres", ); }); + + test("omits the Docker healthcheck on a slim distroless storage image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildStorageContainerSpec({ + ...input, + image: "ghcr.io/supabase/cli/storage:v1.70.3", + }); + expect(spec.healthcheck).toBeUndefined(); + }); + + test("mounts the named volume at /home/nonroot on a slim image so uid 65532 can mkdir the tenant dir", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildStorageContainerSpec({ + ...input, + image: "ghcr.io/supabase/cli/storage:v1.70.3", + }); + expect(spec.binds).toEqual(["supabase_storage_proj:/home/nonroot"]); + expect(spec.env["FILE_STORAGE_BACKEND_PATH"]).toBe("/home/nonroot"); + }); }); diff --git a/apps/cli/src/legacy/commands/start/services/studio.service.ts b/apps/cli/src/legacy/commands/start/services/studio.service.ts index 291b575cc0..15789dbb0d 100644 --- a/apps/cli/src/legacy/commands/start/services/studio.service.ts +++ b/apps/cli/src/legacy/commands/start/services/studio.service.ts @@ -24,6 +24,7 @@ import { join } from "node:path"; import { legacyToDockerPath } from "../../../shared/legacy-docker-path.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { legacyUsesSlimRuntime } from "../../../shared/db-bootstrap/slim-runtime.ts"; /** Container-internal port Studio listens on — hardcoded, never configurable. */ const STUDIO_CONTAINER_PORT = 3000; @@ -186,15 +187,21 @@ export function legacyBuildStudioContainerSpec( containerName: input.containerName, env: legacyBuildStudioEnv({ ...input.env, containerSnippetsPath }), binds, - healthcheck: { - test: [ - "CMD-SHELL", - `node --eval="fetch('http://127.0.0.1:${STUDIO_CONTAINER_PORT}/api/platform/profile').then((r) => {if (!r.ok) throw new Error(r.status)})"`, - ], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - }, + // Distroless slim studio has no /bin/sh; Docker CLI healthchecks are always + // CMD-SHELL. Omitting makes `legacyCheckContainerReady` treat Running as ready. + ...(legacyUsesSlimRuntime(input.image) + ? {} + : { + healthcheck: { + test: [ + "CMD-SHELL", + `node --eval="fetch('http://127.0.0.1:${STUDIO_CONTAINER_PORT}/api/platform/profile').then((r) => {if (!r.ok) throw new Error(r.status)})"`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + }), ports: [{ hostPort: String(input.port), containerPort: String(STUDIO_CONTAINER_PORT) }], restartPolicy: "unless-stopped", networkId: input.networkId, diff --git a/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts index 80728d823d..fe534cd300 100644 --- a/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { legacyBuildStudioContainerSpec, @@ -6,6 +6,10 @@ import { type LegacyBuildStudioEnvInput, } from "./studio.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + const baseEnvInput: LegacyBuildStudioEnvInput = { dbPassword: "postgres", workdir: "/project", @@ -169,4 +173,13 @@ describe("legacyBuildStudioContainerSpec", () => { expect(spec.binds).toEqual(["/project/supabase/snippets:/project/supabase/snippets:rw"]); }); + + test("omits the Docker healthcheck on a slim distroless studio image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildStudioContainerSpec({ + ...baseSpecInput, + image: "ghcr.io/supabase/cli/studio:2026.08.17-sha-0c1da8f", + }); + expect(spec.healthcheck).toBeUndefined(); + }); }); diff --git a/apps/cli/src/legacy/commands/start/services/vector.service.ts b/apps/cli/src/legacy/commands/start/services/vector.service.ts index d1813c2604..1606b56f27 100644 --- a/apps/cli/src/legacy/commands/start/services/vector.service.ts +++ b/apps/cli/src/legacy/commands/start/services/vector.service.ts @@ -35,6 +35,10 @@ import * as ChildProcess from "effect/unstable/process/ChildProcess"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { + legacySlimBusyboxWgetHealthcheck, + legacyUsesSlimRuntime, +} from "../../../shared/db-bootstrap/slim-runtime.ts"; import { legacyRenderStartVectorYaml } from "../lib/template-render.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -344,18 +348,33 @@ export function legacyBuildVectorContainerSpec( dbId: input.dbId, }); - return { - image: input.image, - containerName: input.containerName, - env: input.dockerSocketPlan.env, - entrypoint: "sh", - cmd: ["-c", legacyBuildVectorEntrypointScript(vectorYaml, input.logflareId)], - binds: input.dockerSocketPlan.binds, - healthcheck: LEGACY_VECTOR_HEALTHCHECK, - restartPolicy: "unless-stopped", - securityOpt: input.dockerSocketPlan.securityOpt, - networkId: input.networkId, - networkAliases: LEGACY_VECTOR_NETWORK_ALIASES, - labels: {}, - }; + return legacyUsesSlimRuntime(input.image) + ? { + image: input.image, + containerName: input.containerName, + env: input.dockerSocketPlan.env, + cmd: ["--config", "/etc/vector/vector.yaml"], + secretFiles: [{ containerPath: "/etc/vector/vector.yaml", content: vectorYaml }], + binds: input.dockerSocketPlan.binds, + healthcheck: legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:9001/health"), + restartPolicy: "unless-stopped", + securityOpt: input.dockerSocketPlan.securityOpt, + networkId: input.networkId, + networkAliases: LEGACY_VECTOR_NETWORK_ALIASES, + labels: {}, + } + : { + image: input.image, + containerName: input.containerName, + env: input.dockerSocketPlan.env, + entrypoint: "sh", + cmd: ["-c", legacyBuildVectorEntrypointScript(vectorYaml, input.logflareId)], + binds: input.dockerSocketPlan.binds, + healthcheck: LEGACY_VECTOR_HEALTHCHECK, + restartPolicy: "unless-stopped", + securityOpt: input.dockerSocketPlan.securityOpt, + networkId: input.networkId, + networkAliases: LEGACY_VECTOR_NETWORK_ALIASES, + labels: {}, + }; } diff --git a/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts index 24762e5f5b..d3669438b7 100644 --- a/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, test } from "@effect/vitest"; +import { afterEach, vi } from "vitest"; import { Deferred, Effect, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -15,6 +16,10 @@ import { type LegacyVectorDockerSocketPlan, } from "./vector.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + /** Matches the standing `mockSpawner` shape in `image-prepull.unit.test.ts`. */ function mockSpawner( handler: (args: ReadonlyArray) => { exitCode: number; stdout?: string; stderr?: string }, @@ -295,6 +300,30 @@ describe("legacyBuildVectorContainerSpec", () => { expect(script).toContain('"supabase_vector_proj"'); expect(script).toContain('.appname == "supabase_kong_proj"'); }); + + test("delivers vector.yaml via secretFiles and uses busybox wget on a slim image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildVectorContainerSpec({ + ...base, + image: "ghcr.io/supabase/cli/vector:0.53.0", + }); + expect(spec.entrypoint).toBeUndefined(); + expect(spec.cmd).toEqual(["--config", "/etc/vector/vector.yaml"]); + expect(spec.secretFiles).toEqual([ + { + containerPath: "/etc/vector/vector.yaml", + content: expect.stringContaining('"supabase_vector_proj"'), + }, + ]); + expect(spec.healthcheck?.test).toEqual([ + "CMD", + "/bin/busybox", + "wget", + "-q", + "--spider", + "http://127.0.0.1:9001/health", + ]); + }); }); describe("legacyResolveDockerDaemonHost", () => { diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 2d6034999d..6d802ed365 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -178,6 +178,7 @@ import { import { legacyBuildMailpitContainerSpec } from "./services/mailpit.service.ts"; import { legacyBuildRealtimeContainerSpec } from "./services/realtime.service.ts"; import { LEGACY_REALTIME_TENANT_ID } from "../../shared/db-bootstrap/realtime-env.ts"; +import { legacyUsesSlimRuntime } from "../../shared/db-bootstrap/slim-runtime.ts"; import { legacyBuildPostgrestContainerSpec } from "./services/postgrest.service.ts"; import { legacyBuildStorageContainerSpec } from "./services/storage.service.ts"; import { legacyBuildImgproxyContainerSpec } from "./services/imgproxy.service.ts"; @@ -1667,6 +1668,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta const started = new Map(); let postgrestGateway: LegacyHealthCheckPostgrestGateway | undefined; let edgeRuntimeGateway: LegacyHealthCheckPostgrestGateway | undefined; + let storageGateway: LegacyHealthCheckPostgrestGateway | undefined; let storageContainerId: string | undefined; const imagePlanByService = new Map(imagePlan.map((entry) => [entry.service, entry.image])); for (const entry of LEGACY_START_SERVICES) { @@ -1815,6 +1817,18 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta } if (entry.service === "storage") { storageContainerId = spec.containerName; + // The slim Storage spec declares no Docker healthcheck (distroless — + // see `storage.service.ts`), so without this gateway the health wait + // would accept it as soon as it is `Running` and bucket seeding below + // would race its startup. Needs Kong to reach it; with Kong excluded + // there is no route to probe, so fall back to the Running-only check. + if (legacyUsesSlimRuntime(spec.image) && gates.kong) { + storageGateway = { + containerId: spec.containerName, + apiExternalUrl: values.apiUrl, + secretKey: values.secretKey, + }; + } } } @@ -1823,6 +1837,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta started, postgrestGateway, edgeRuntimeGateway, + storageGateway, storageContainerId, }; }).pipe( @@ -1886,7 +1901,8 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // but never re-entering these later steps once the DB bootstrap has // already returned. if (bringUpResult.kind === "started") { - const { started, postgrestGateway, edgeRuntimeGateway, storageContainerId } = bringUpResult; + const { started, postgrestGateway, edgeRuntimeGateway, storageGateway, storageContainerId } = + bringUpResult; // Wraps steps 9-11 below (bulk health wait, the ignore-health-check // storage-only recheck-and-seed, the success-path bucket seeding, and @@ -1980,18 +1996,27 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta projectRef: "", config: effectiveLocalStorageConfig, }); + // Shared by every gateway probe below (the bulk wait and the + // storage-only recheck), so both trust the same local Kong CA. + const withLocalKongCa = (effect: Effect.Effect) => + localKongCa === undefined + ? effect + : effect.pipe( + Effect.provideService( + FetchHttpClient.Fetch, + legacyStorageGatewayFetch(localKongCa), + ), + ); // Keep the synthetic value out of project dotenv resolution and container environments. legacyConfigureLoopbackProxyBypass(); - const healthResult = yield* legacyWaitForHealthyServices(spawner, [...started.keys()], { - postgrest: postgrestGateway, - edgeRuntime: edgeRuntimeGateway, - images: started, - }).pipe( - Effect.result, - localKongCa !== undefined - ? Effect.provideService(FetchHttpClient.Fetch, legacyStorageGatewayFetch(localKongCa)) - : (effect) => effect, - ); + const healthResult = yield* withLocalKongCa( + legacyWaitForHealthyServices(spawner, [...started.keys()], { + postgrest: postgrestGateway, + edgeRuntime: edgeRuntimeGateway, + storage: storageGateway, + images: started, + }), + ).pipe(Effect.result); if (Result.isFailure(healthResult)) { const error = healthResult.failure; if (flags.ignoreHealthCheck && legacyIsUnhealthyStartError(error)) { @@ -2012,10 +2037,11 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // `images` is intentionally the whole run's registry, not scoped to // this one-container watch list — the hint can only ever key off // containers that actually appear in this call's own failures. - const storageHealthResult = yield* legacyWaitForHealthyServices( - spawner, - [storageContainerId], - { images: started }, + const storageHealthResult = yield* withLocalKongCa( + legacyWaitForHealthyServices(spawner, [storageContainerId], { + storage: storageGateway, + images: started, + }), ).pipe(Effect.result); if (Result.isSuccess(storageHealthResult)) { const seedResult = yield* legacySeedBucketsRun({ diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts index 10de9bd618..c48d05eea1 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -322,9 +322,12 @@ function freshVolumeRoute( function mockStorageBucketHttpClient() { const createdBucketRequests: Array = []; const createdBucketBodies: Array = []; + /** Every request in order, so a test can assert a readiness probe preceded seeding. */ + const requests: Array<{ method: string; url: string }> = []; const layer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => { + requests.push({ method: request.method, url: request.url }); if (request.method === "GET" && request.url.includes("/storage/v1/bucket")) { return Effect.succeed( HttpClientResponse.fromWeb( @@ -362,7 +365,7 @@ function mockStorageBucketHttpClient() { ); }), ); - return { layer, createdBucketRequests, createdBucketBodies }; + return { layer, createdBucketRequests, createdBucketBodies, requests }; } /** @@ -2645,9 +2648,35 @@ content_path = "./supabase/templates/custom_notice.html" return Effect.gen(function* () { yield* legacyStart(flags({ exclude: ["edge-runtime"] })); expect(http.createdBucketRequests).toHaveLength(1); + // docker.io Storage carries its own Docker healthcheck, so readiness + // never goes through the gateway. + expect(http.requests.some((entry) => entry.url.includes("/storage/v1/status"))).toBe(false); }).pipe(Effect.provide(layer)); }); + it.live("probes storage's own /status before seeding a bucket on the slim image", () => { + // The slim Storage spec carries no Docker healthcheck, so without the + // gateway probe seeding would fire the moment the container is Running. + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const http = mockStorageBucketHttpClient(); + const { layer } = setup({ + configContents: 'project_id = "demo"\n[storage.buckets.avatars]\npublic = false\n', + route: freshVolumeRoute(defaultRoute()), + httpClientLayer: http.layer, + }); + return Effect.gen(function* () { + yield* legacyStart(flags({ exclude: ["edge-runtime"] })); + const probeIndex = http.requests.findIndex( + (entry) => entry.method === "HEAD" && entry.url.includes("/storage/v1/status"), + ); + const seedIndex = http.requests.findIndex( + (entry) => entry.method === "POST" && entry.url.includes("/storage/v1/bucket"), + ); + expect(probeIndex).toBeGreaterThanOrEqual(0); + expect(seedIndex).toBeGreaterThan(probeIndex); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }); + it.live( "does not seed a configured bucket on a non-fresh volume, even with storage enabled", () => { diff --git a/apps/cli/src/legacy/commands/start/start.services.unit.test.ts b/apps/cli/src/legacy/commands/start/start.services.unit.test.ts index 028dad8bed..98ca72146e 100644 --- a/apps/cli/src/legacy/commands/start/start.services.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/start.services.unit.test.ts @@ -1,10 +1,15 @@ import { CliConfigSchema, type CliConfig } from "@supabase/config"; import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { LocalServiceVersionOverrides } from "../../../shared/services/services.shared.ts"; import { legacyServiceContainerIds, localDbContainerId } from "../../shared/legacy-docker-ids.ts"; import { LEGACY_SERVICE_CATALOG } from "../../shared/legacy-service-catalog.ts"; -import { legacyResolveStartGates, type LegacyStartGates } from "./start.gates.ts"; +import { + legacyResolveStartGates, + legacyResolveStartImagePlan, + type LegacyStartGates, +} from "./start.gates.ts"; import { LEGACY_START_SERVICES, legacyStartServiceMeta } from "./start.services.ts"; describe("LEGACY_START_SERVICES", () => { @@ -212,3 +217,46 @@ describe("LEGACY_START_SERVICES enabledGate cross-check against start.gates.ts", expect(ungated.map((entry) => entry.service).toSorted()).toEqual(["postgres"]); }); }); + +describe("legacyResolveStartImagePlan under SUPABASE_USE_SLIM_IMAGES", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + const allGatesOpen: LegacyStartGates = { + kong: true, + gotrue: true, + mailpit: true, + realtime: true, + postgrest: true, + storage: true, + imgproxy: true, + logflare: true, + vector: true, + pgMeta: true, + studio: true, + supavisor: true, + edgeRuntime: true, + }; + + const imageFor = (service: string, serviceVersions: LocalServiceVersionOverrides = {}) => + legacyResolveStartImagePlan(allGatesOpen, serviceVersions).find( + (entry) => entry.service === service, + )?.image; + + it("plans docker.io images while the flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(imageFor("gotrue")).toBe("supabase/gotrue:v2.196.0"); + expect(imageFor("vector")).toBe("timberio/vector:0.53.0-alpine"); + expect(imageFor("supavisor", { pooler: "2.0.0" })).toBe("supabase/supavisor:2.0.0"); + }); + + it("plans slim images when the flag is on, keeping unmapped services on docker.io", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(imageFor("gotrue")).toBe("ghcr.io/supabase/cli/auth:v2.196.0"); + expect(imageFor("logflare")).toBe("ghcr.io/supabase/cli/analytics:v1.50.4"); + expect(imageFor("vector")).toBe("ghcr.io/supabase/cli/vector:0.53.0"); + expect(imageFor("supavisor", { pooler: "2.0.0" })).toBe("ghcr.io/supabase/cli/pooler:v2.0.0"); + expect(imageFor("kong")).toBe("library/kong:2.8.1"); + }); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts index 8a1dc49dd5..885608cac8 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts @@ -489,6 +489,67 @@ export function legacyVolumeExists( ); } +/** + * Whether an EXISTING database volume is accessible (readable AND writable) to the given (slim) + * Postgres image's own user, via `docker run --rm --entrypoint /usr/bin/sh -v :/probe + * -c "test -r /probe/PG_VERSION && test -w /probe"` — cheaper than a real bring-up + * attempt, and runs before any db container is created. Read alone is not enough: Postgres must + * write `postmaster.pid`/WAL under PGDATA, so a read-only-accessible volume would still + * crash-loop past this guard. The script exits `0` when accessible, `1` when not (e.g. a + * docker.io-initialized volume's `700`-mode PGDATA dirs, owned by that image's postgres uid, + * blocking the slim image's non-root `65532`). Any OTHER exit (spawn failure, or Docker's own + * `docker run` convention of `125`/`126`/`127` for a daemon/exec-level problem rather than the + * probed command's own exit) propagates as a genuine docker-run failure instead of being folded + * into the `1` case. + */ +export function legacyIsVolumeAccessibleToImage( + spawner: Spawner, + image: string, + name: string, +): Effect.Effect { + const fail = (message: string): LegacyContainerCreateError => + new LegacyContainerCreateError({ message, reason: "runtime" }); + return Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli( + spawner, + [ + "run", + "--rm", + "--entrypoint", + "/usr/bin/sh", + "-v", + `${name}:/probe`, + image, + "-c", + "test -r /probe/PG_VERSION && test -w /probe", + ], + { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, + ).pipe( + Effect.mapError((cause) => + fail( + `failed to probe database volume access: ${legacyDescribeContainerCliFailure(cause)}`, + ), + ), + ); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), legacyCollectText(child.stderr)], + { concurrency: "unbounded" }, + ).pipe(Effect.mapError(() => fail("failed to probe database volume access"))); + if (exitCode === 0) return true; + if (exitCode === 1) return false; + const message = stderr.trim(); + return yield* Effect.fail( + fail( + message.length > 0 + ? `failed to probe database volume access: ${message}` + : `failed to probe database volume access: exit ${exitCode}`, + ), + ); + }), + ); +} + /** `docker container rm -f ` (or `docker rm -f`) failed. */ export class LegacyContainerRemoveError extends Data.TaggedError("LegacyContainerRemoveError")<{ readonly message: string; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts index b415f0cbae..c4c4f8204a 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -18,7 +18,10 @@ * run-to-completion container on the SAME Docker network as `db` — Go's * `DockerStart` defaults `NetworkMode` to `utils.NetId` when unset, * `docker.go:379-383`), each gated on its own service's `enabled` flag and none - * of which touch `conn` directly: + * of which touch `conn` directly. Slim images skip the Realtime and Storage + * jobs: those images migrate (and Realtime seeds) from their long-running + * entrypoint. Slim Auth still runs a one-shot `migrate` under the baked + * `auth` ENTRYPOINT. * - `initRealtimeJob` (`start.go:268-295`) — reuses * `./realtime-env.ts`'s `legacyBuildRealtimeEnv`, which builds * the byte-identical env-var literal Go's own `initRealtimeJob` embeds @@ -160,6 +163,7 @@ import { } from "../legacy-vault.ts"; import { legacyEnsureImagesCached, type LegacyImagePrepullError } from "./image-prepull.ts"; import { legacyResolvePinnedImage } from "./pinned-image.ts"; +import { legacyUsesSlimRuntime } from "./slim-runtime.ts"; import { LEGACY_COMPOSE_PROJECT_LABEL } from "./container-lifecycle.ts"; import { LEGACY_REALTIME_TENANT_ID, legacyBuildRealtimeEnv } from "./realtime-env.ts"; import { LEGACY_START_DB_GLOBALS_SQL } from "./templates/db-globals.sql.ts"; @@ -802,26 +806,33 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( const dbPassword = legacyStartInternalDbPassword(input.dbUrl); if (input.config.realtime.enabled) { - yield* legacyRunStartMigrateJob(spawner, { - image: input.images.realtime, - networkId: input.networkId, - projectId: input.projectId, - projectEnvValues: input.projectEnvValues, - debug: input.debug, - env: legacyBuildRealtimeEnv({ - ipVersion: input.config.realtime.ip_version, - maxHeaderLength: input.config.realtime.max_header_length, - dbHost, - dbPassword, - jwtSecret: input.jwtSecret, - jwks: input.jwks, - }), - cmd: [ - "/app/bin/realtime", - "eval", - `{:ok, _} = Application.ensure_all_started(:realtime)\n{:ok, _} = Realtime.Tenants.health_check("${LEGACY_REALTIME_TENANT_ID}")`, - ], - }); + // Slim realtime's ENTRYPOINT (`tini` + `/app/entry.sh`) already runs + // `/app/bin/migrate`, seeds when `SEED_SELF_HOST=true` (set by + // `legacyBuildRealtimeEnv`), then execs the server. The docker.io one-shot + // `eval` health_check would otherwise start a second BEAM against the + // same DB (and hang if it inherited `entry.sh`). Skip it. + if (!legacyUsesSlimRuntime(input.images.realtime)) { + yield* legacyRunStartMigrateJob(spawner, { + image: input.images.realtime, + networkId: input.networkId, + projectId: input.projectId, + projectEnvValues: input.projectEnvValues, + debug: input.debug, + env: legacyBuildRealtimeEnv({ + ipVersion: input.config.realtime.ip_version, + maxHeaderLength: input.config.realtime.max_header_length, + dbHost, + dbPassword, + jwtSecret: input.jwtSecret, + jwks: input.jwks, + }), + cmd: [ + "/app/bin/realtime", + "eval", + `{:ok, _} = Application.ensure_all_started(:realtime)\n{:ok, _} = Realtime.Tenants.health_check("${LEGACY_REALTIME_TENANT_ID}")`, + ], + }); + } } if (input.config.storage.enabled) { // `legacyStartStorageMigrateEnv` parses `storage.file_size_limit` via @@ -837,34 +848,43 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( // fix already applied to `resolveDbHealthTimeoutSeconds` and the // long-running Storage container's own file-size-limit parsing // (`start.handler.ts`). - const storageEnv = yield* Effect.try({ - try: () => - legacyStartStorageMigrateEnv({ - targetMigration: input.storageTargetMigration, - anonKey: input.anonKey, - serviceRoleKey: input.serviceRoleKey, - jwtSecret: input.jwtSecret, - dbHost, - dbPassword, - fileSizeLimit: input.config.storage.file_size_limit, - }), - catch: (cause) => - new LegacyDbSetupError({ - message: `invalid config for storage: ${errMessage(cause)}`, - reason: "invalid_config", - }), - }); - yield* legacyRunStartMigrateJob(spawner, { - image: input.images.storage, - networkId: input.networkId, - projectId: input.projectId, - projectEnvValues: input.projectEnvValues, - debug: input.debug, - env: storageEnv, - cmd: ["node", "dist/scripts/migrate-call.js"], - }); + // Slim storage has no `dist/scripts/migrate-call.js` (the docker.io one-shot + // cmd). Tenant migrations run when the long-running server boots, and the slim + // postgres image already applies the bundled storage schema at initdb. + if (!legacyUsesSlimRuntime(input.images.storage)) { + const storageEnv = yield* Effect.try({ + try: () => + legacyStartStorageMigrateEnv({ + targetMigration: input.storageTargetMigration, + anonKey: input.anonKey, + serviceRoleKey: input.serviceRoleKey, + jwtSecret: input.jwtSecret, + dbHost, + dbPassword, + fileSizeLimit: input.config.storage.file_size_limit, + }), + catch: (cause) => + new LegacyDbSetupError({ + message: `invalid config for storage: ${errMessage(cause)}`, + reason: "invalid_config", + }), + }); + yield* legacyRunStartMigrateJob(spawner, { + image: input.images.storage, + networkId: input.networkId, + projectId: input.projectId, + projectEnvValues: input.projectEnvValues, + debug: input.debug, + env: storageEnv, + cmd: ["node", "dist/scripts/migrate-call.js"], + }); + } } if (input.config.auth.enabled) { + // Slim auth bakes `/usr/local/bin/auth` as ENTRYPOINT, so `["gotrue", "migrate"]` + // would become `auth gotrue migrate`. The docker.io image has an empty + // entrypoint and expects the binary name in argv. + const slimAuth = legacyUsesSlimRuntime(input.images.auth); yield* legacyRunStartMigrateJob(spawner, { image: input.images.auth, networkId: input.networkId, @@ -879,7 +899,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( dbHost, dbPassword, }), - cmd: ["gotrue", "migrate"], + cmd: slimAuth ? ["migrate"] : ["gotrue", "migrate"], }); } }); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts index 8bbeea2160..d0b281e139 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts @@ -5,6 +5,7 @@ import type { CliConfig } from "@supabase/config"; import { CliConfigSchema } from "@supabase/config"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; +import { afterEach, vi } from "vitest"; import { Deferred, Effect, FileSystem, Layer, Path, Schema, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -268,6 +269,10 @@ const run = ( ); describe("legacyStartSetupLocalDatabase", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + describe("PG <= 14 vs PG >= 15 schema branch", () => { it.effect("PG14: execs globals + the PG14 initial schema, runs no one-shot docker jobs", () => { const workdir = makeWorkdir(); @@ -363,6 +368,37 @@ describe("legacyStartSetupLocalDatabase", () => { ); }); + it.effect( + "slim refs: skips realtime and storage one-shot jobs and passes migrate as auth argv", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run( + baseInput(workdir, session, { + majorVersion: 15, + images: { + realtime: "ghcr.io/supabase/cli/realtime:v2.129.3", + storage: "ghcr.io/supabase/cli/storage:v1.70.3", + auth: "ghcr.io/supabase/cli/auth:v2.196.0", + }, + }), + out, + docker, + ).pipe( + Effect.map(() => { + expect(docker.runs.map((job) => job.image)).toEqual([ + "ghcr.io/supabase/cli/auth:v2.196.0", + ]); + expect(docker.runs[0]?.cmd).toEqual(["migrate"]); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + it.effect( "labels every one-shot job with the project's Docker labels, matching Go's DockerStart (review: Codex, PR #6022)", () => { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts b/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts index f791a6f6fe..4c3f7a35cf 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts @@ -57,6 +57,16 @@ const LEGACY_POSTGREST_READY_PATH = "/rest-admin/v1/ready"; */ const LEGACY_EDGE_RUNTIME_READY_PATH = "/functions/v1/_internal/health"; +/** + * Storage's own `/status` (the endpoint its docker.io HEALTHCHECK probes with + * `wget --spider`), reached through Kong's `/storage/v1/` route. Only the slim + * image needs it: distroless Storage carries no Docker healthcheck (no shell + * for `CMD-SHELL`, see `slim-runtime.ts`), so {@link legacyCheckContainerReady} + * would report it ready the moment it is `Running` — before it can answer the + * bucket-seeding calls `start` makes right after this gate. + */ +const LEGACY_STORAGE_READY_PATH = "/storage/v1/status"; + /** Identifies a single container's readiness failure this round. */ export interface LegacyHealthCheckFailure { /** @@ -138,6 +148,8 @@ export interface LegacyWaitForHealthyServicesOptions { readonly postgrest?: LegacyHealthCheckPostgrestGateway; /** See {@link LEGACY_EDGE_RUNTIME_READY_PATH}'s doc comment for why this reuses the same gateway shape as {@link postgrest}. */ readonly edgeRuntime?: LegacyHealthCheckPostgrestGateway; + /** Set only for the slim Storage image — see {@link LEGACY_STORAGE_READY_PATH}. */ + readonly storage?: LegacyHealthCheckPostgrestGateway; /** Each watched container's already-resolved image, keyed by container name. */ readonly images?: ReadonlyMap; } @@ -350,6 +362,7 @@ export function legacyWaitForHealthyServices( const timeoutSeconds = opts.timeoutSeconds ?? LEGACY_HEALTH_CHECK_TIMEOUT_SECONDS; const postgrest = opts.postgrest; const edgeRuntime = opts.edgeRuntime; + const storage = opts.storage; const checkOne = (containerId: string): Effect.Effect => { if (postgrest !== undefined && containerId === postgrest.containerId) { @@ -358,6 +371,9 @@ export function legacyWaitForHealthyServices( if (edgeRuntime !== undefined && containerId === edgeRuntime.containerId) { return legacyCheckHttpReady(edgeRuntime, LEGACY_EDGE_RUNTIME_READY_PATH); } + if (storage !== undefined && containerId === storage.containerId) { + return legacyCheckHttpReady(storage, LEGACY_STORAGE_READY_PATH); + } return legacyCheckContainerReady(spawner, containerId); }; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts index 731d96751a..6167c3ac55 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts @@ -151,6 +151,8 @@ const runningStarting = JSON.stringify({ Health: { Status: "starting" }, }); const notRunning = JSON.stringify({ Status: "exited", Running: false }); +/** A container with no Docker healthcheck at all, like every distroless slim image. */ +const runningNoHealth = JSON.stringify({ Status: "running", Running: true }); /** * `legacyWaitForHealthyServices` structurally requires `HttpClient.HttpClient` @@ -653,6 +655,77 @@ describe("legacyWaitForHealthyServices", () => { ); }); + describe("slim Storage HTTP-HEAD readiness", () => { + const storageGateway: LegacyHealthCheckPostgrestGateway = { + containerId: "supabase_storage_proj", + apiExternalUrl: "http://127.0.0.1:54321", + secretKey: "sb_secret_local", + }; + + function httpLayer(status: number) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.method).toBe("HEAD"); + expect(request.url).toBe("http://127.0.0.1:54321/storage/v1/status"); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status })), + ); + }), + ); + } + + it.effect("succeeds on a 200 instead of trusting a merely-Running container", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningNoHealth); + + const exit = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_storage_proj"], { + timeoutSeconds: 1, + storage: storageGateway, + }).pipe(Effect.provide(httpLayer(200)), Effect.exit); + + expect(Exit.isSuccess(exit)).toBe(true); + expect(inspectCalls(mock)).toHaveLength(0); + }), + ); + + it.effect("keeps waiting while Storage is still starting up", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningNoHealth); + + const fiber = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_storage_proj"], { + timeoutSeconds: 1, + storage: storageGateway, + }).pipe( + Effect.provide(httpLayer(503)), + withSilencedStderr, + Effect.forkChild({ startImmediately: true }), + ); + + yield* TestClock.adjust("1 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyHealthCheckTimeoutError); + expect(error.unhealthy).toEqual([ + { containerId: "supabase_storage_proj", reason: "unexpected status 503" }, + ]); + }), + ); + + it.effect("falls back to the Docker state when no gateway is configured", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningNoHealth); + + const exit = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_storage_proj"], { + timeoutSeconds: 1, + }).pipe(Effect.provide(unusedHttpClientLayer), Effect.exit); + + expect(Exit.isSuccess(exit)).toBe(true); + expect(inspectCalls(mock)).toHaveLength(1); + }), + ); + }); + describe("Edge Runtime HTTP-HEAD readiness", () => { function edgeRuntimeGateway(secretKey: string): LegacyHealthCheckPostgrestGateway { return { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts index 89b70d18f1..c36ae05714 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts @@ -1,4 +1,6 @@ +import { dockerImageForService } from "@supabase/stack/versions"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { replaceImageTag, type LocalServiceVersionName, @@ -26,5 +28,13 @@ export function legacyResolvePinnedImage( ): string { const baseImage = dockerfileServiceImage(alias); const pinnedVersion = serviceVersions[localServiceName]; - return pinnedVersion === undefined ? baseImage : replaceImageTag(baseImage, pinnedVersion); + if (pinnedVersion === undefined) { + return baseImage; + } + // A verbatim tag swap would be wrong on slim refs whose tag scheme differs + // from docker.io's (`pooler`/`analytics` pins are unprefixed on docker.io but + // `v`-prefixed under `ghcr.io/supabase/cli`), so let the catalog normalize. + return slimImagesEnabled() + ? dockerImageForService(localServiceName, pinnedVersion) + : replaceImageTag(baseImage, pinnedVersion); } diff --git a/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.unit.test.ts new file mode 100644 index 0000000000..54efe8a308 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.unit.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { legacyResolvePinnedImage } from "./pinned-image.ts"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("legacyResolvePinnedImage", () => { + it("resolves docker.io images while the slim flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(legacyResolvePinnedImage("gotrue", "auth", {})).toBe("supabase/gotrue:v2.196.0"); + expect(legacyResolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" })).toBe( + "supabase/gotrue:v2.100.0", + ); + expect(legacyResolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" })).toBe( + "supabase/supavisor:2.0.0", + ); + }); + + it("resolves slim images when the flag is on", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(legacyResolvePinnedImage("gotrue", "auth", {})).toBe( + "ghcr.io/supabase/cli/auth:v2.196.0", + ); + expect(legacyResolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" })).toBe( + "ghcr.io/supabase/cli/auth:v2.100.0", + ); + }); + + // A pin written to `supabase/.temp/-version` follows docker.io's tag + // scheme, which is unprefixed for these two while their slim tags are not. + it("normalizes pooler and analytics pins onto the slim tag scheme", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(legacyResolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" })).toBe( + "ghcr.io/supabase/cli/pooler:v2.0.0", + ); + expect(legacyResolvePinnedImage("logflare", "analytics", { analytics: "1.4.0" })).toBe( + "ghcr.io/supabase/cli/analytics:v1.4.0", + ); + }); + + it("keeps the postgres pin path stable across the flag", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(legacyResolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" })).toBe( + "supabase/postgres:17.4.1.1", + ); + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + expect(legacyResolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" })).toBe( + "ghcr.io/supabase/cli/postgres:17.4.1.1", + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts index d740b853ba..e55fe24ca0 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts @@ -22,6 +22,7 @@ import type { CliConfig } from "@supabase/config"; +import { isSlimImageRef, slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { localDbContainerId } from "../legacy-docker-ids.ts"; import { legacyToDockerPath } from "../legacy-docker-path.ts"; import { encodeToml } from "../legacy-go-output.encoders.ts"; @@ -50,11 +51,62 @@ const LEGACY_POSTGRES_PASSWORD = "postgres"; */ const LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH = "/etc/postgresql-custom/pgsodium_root.key"; +/** + * The post-migration hook path both image families read: `supabase/postgres`'s + * bundled `migrate.sh` execs `psql -v ON_ERROR_STOP=1 -U supabase_admin -f + * /etc/postgresql.schema.sql` as its last step when the file exists. The + * docker.io entrypoint heredocs it (see + * {@link legacyPostgresEntrypointScriptPg15}); the slim image's own entrypoint + * has no heredoc seam, so the slim path stages the same bytes at the same path + * via `secretFiles` instead — see {@link legacyPostgresSlimBootFields}. + */ +const LEGACY_POSTGRES_SCHEMA_SQL_PATH = "/etc/postgresql.schema.sql"; + +/** + * Slim `migrate.sh` demotes `postgres` after bundled migrations + * (`10000000000000_demote-postgres.sql`). The local CLI still talks to the + * cluster as that role, and storage-api's vector-bucket migrations + * `CREATE EXTENSION vector` as it — docker.io's local postgres stays a + * superuser. Re-promote and pre-create `vector` in the postinit hook, which + * runs as `supabase_admin` after demote and before the CLI connects. + */ +const LEGACY_POSTGRES_SLIM_POSTINIT_SQL = `ALTER ROLE postgres WITH SUPERUSER; +CREATE EXTENSION IF NOT EXISTS vector; +`; + /** Go's `container.HealthConfig` literals (`apps/cli-go/internal/db/start/start.go:85-90`). */ const LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS = 10; const LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS = 2; const LEGACY_POSTGRES_HEALTHCHECK_RETRIES = 3; +/** The docker.io image's healthcheck: `pg_isready` alone is a sufficient readiness probe. */ +const LEGACY_POSTGRES_HEALTHCHECK_TEST: ReadonlyArray = [ + "CMD", + "pg_isready", + "-U", + "postgres", + "-h", + "127.0.0.1", + "-p", + "5432", +]; + +/** + * The slim image's first boot runs initdb + bundled migrations against a TEMPORARY server + * before `entry.sh` execs the final `postgres` process, so PID 1 is still the entrypoint + * shell during that window and a bare `pg_isready` would pass against the temporary + * server. Gate on the final Postgres process the same way `packages/stack`'s own slim + * Docker healthcheck does (`postgresDockerHealthCheck`, + * `packages/stack/src/services/postgres.ts`). + */ +const LEGACY_POSTGRES_SLIM_HEALTHCHECK_TEST: ReadonlyArray = [ + "CMD", + "sh", + "-ec", + // Linux /proc/1/comm truncates `.postgres-wrapped` to 15 characters. + 'case "$(cat /proc/1/comm)" in postgres|.postgres-wrapp) pg_isready -U postgres -h 127.0.0.1 -p 5432 ;; *) exit 1 ;; esac', +]; + /** Go's `utils.DbAliases` (`apps/cli-go/internal/utils/config.go:36`). */ const LEGACY_POSTGRES_NETWORK_ALIASES: ReadonlyArray = ["db", "db.supabase.internal"]; @@ -137,9 +189,7 @@ export interface LegacyPostgresStartServiceInput { export function legacyPostgresSettingsToPostgresConfig( settings: CliConfig["db"]["settings"], ): string { - const defined = Object.fromEntries( - Object.entries(settings ?? {}).filter(([, value]) => value !== undefined), - ); + const defined = Object.fromEntries(legacyDefinedPostgresSettings(settings)); if (Object.keys(defined).length === 0) { return LEGACY_POSTGRES_CONFIG_HEADER; } @@ -147,6 +197,37 @@ export function legacyPostgresSettingsToPostgresConfig( return `${LEGACY_POSTGRES_CONFIG_HEADER}${toml}`; } +/** + * The `[db.settings]` keys the user actually set — an unset field must never reach either + * renderer below: both the postgresql.conf TOML renderer and the `-c` argv renderer must + * emit only keys the user actually set. + */ +function legacyDefinedPostgresSettings( + settings: CliConfig["db"]["settings"], +): ReadonlyArray { + return Object.entries(settings ?? {}).filter( + (entry): entry is [string, string | number | boolean] => entry[1] !== undefined, + ); +} + +/** + * The same `[db.settings]` source as {@link legacyPostgresSettingsToPostgresConfig}, + * rendered as repeated `-c key=value` argv pairs instead of `postgresql.conf` + * lines. The slim image's entrypoint ends in `exec postgres -D $PGDATA "$@"`, so + * the container's trailing argv reaches the server verbatim — no conf-file append + * seam, and no shell quoting to get right, since each pair travels as its own + * argv element. Values are unquoted on purpose: the TOML renderer's single quotes + * are TOML syntax, whereas `postgres -c` takes the raw value. + */ +export function legacyPostgresSettingsToConfigArgs( + settings: CliConfig["db"]["settings"], +): ReadonlyArray { + return legacyDefinedPostgresSettings(settings).flatMap(([key, value]) => [ + "-c", + `${key}=${String(value)}`, + ]); +} + /** * Port of Go's `config.VersionCompare` (`apps/cli-go/pkg/config/config.go:885-899`) * — NOT a real semver comparator. A dotted version with more than 3 components @@ -282,7 +363,7 @@ function legacyPostgresExtraEnv( function legacyPostgresEntrypointScriptPg15(postgresConfig: string, args = ""): string { return ( "\n" + - "cat <<'EOF' > /etc/postgresql.schema.sql && \\\n" + + `cat <<'EOF' > ${LEGACY_POSTGRES_SCHEMA_SQL_PATH} && \\\n` + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + `exec docker-entrypoint.sh postgres -D /etc/postgresql ${args}\n` + `${LEGACY_START_DB_SCHEMA_SQL}\n` + @@ -333,7 +414,7 @@ function legacyPostgresEntrypointScriptPg14(postgresConfig: string, args = ""): function legacyPostgresEntrypointScriptRestore(postgresConfig: string): string { return ( "\n" + - "cat <<'EOF' > /etc/postgresql.schema.sql && \\\n" + + `cat <<'EOF' > ${LEGACY_POSTGRES_SCHEMA_SQL_PATH} && \\\n` + "cat <<'EOF' > /docker-entrypoint-initdb.d/migrate.sh && \\\n" + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + "exec docker-entrypoint.sh postgres -D /etc/postgresql\n" + @@ -348,6 +429,86 @@ function legacyPostgresEntrypointScriptRestore(postgresConfig: string): string { ); } +/** + * Whether this container runs the `ghcr.io/supabase/cli` slim Postgres build rather than the + * docker.io one. The single gate shared by both spec builders and `start-database.ts`'s + * `--from-backup` refusal, so the guard can never drift from what the builders actually emit. + */ +export function legacyIsSlimPostgresImage(image: string): boolean { + return slimImagesEnabled() && isSlimImageRef(image); +} + +/** + * The bytes the slim image's `migrate.sh` runs as `supabase_admin` after bundled + * migrations: schema.sql + webhook.sql + _supabase.sql, then a slim-only postinit + * that re-promotes `postgres` (the bundled demote migration leaves it + * NOSUPERUSER) and creates `vector` for storage-api. + */ +const LEGACY_POSTGRES_SLIM_SCHEMA_SQL = `${LEGACY_START_DB_SCHEMA_SQL}\n${LEGACY_START_DB_WEBHOOK_SQL}\n${LEGACY_START_DB_SUPABASE_SQL}\n${LEGACY_POSTGRES_SLIM_POSTINIT_SQL}`; + +/** + * The image-dependent half of a Postgres container spec: how the entrypoint is + * driven, which env it reads, and which files have to be staged before it runs. + * Shared by the real `db` container and the shadow, whose slim wiring differs + * only by the shadow's extra worker-cap argv. + */ +type LegacyPostgresBootFields = Pick< + LegacyStartContainerSpec, + "env" | "entrypoint" | "cmd" | "secretFiles" +>; + +/** + * Slim-image wiring. The slim build has none of the seams the docker.io + * entrypoint script relies on (no `docker-entrypoint.sh`, no writable + * `/etc/postgresql/postgresql.conf`, and a non-root `65532` runtime user), so + * instead of overriding the entrypoint this keeps the image's own + * `sh /usr/local/bin/entry.sh` and drives it through the two seams it does + * expose: + * + * - **Trailing argv** — `entry.sh` ends in `exec postgres -D $PGDATA "$@"`, so + * `[db.settings]` travels as `-c key=value` pairs + * ({@link legacyPostgresSettingsToConfigArgs}) rather than a conf-file append. + * Everything the docker.io script appends by hand (`listen_addresses`, `port`, + * `wal_level`, the `host all all all scram-sha-256` HBA rule) `entry.sh` + * already applies itself on first boot. + * - **{@link LEGACY_POSTGRES_SCHEMA_SQL_PATH}** — staged as a `secretFiles` + * entry so the bundled `migrate.sh` finds it and runs it exactly once, at + * initdb, as `supabase_admin` under `ON_ERROR_STOP=1`. That is the same + * post-migration hook the docker.io image runs, which is why this needs no + * first-boot bookkeeping of its own: a reused data directory skips + * `migrate.sh` entirely, so the non-idempotent schema SQL cannot re-apply. + * + * `PGSODIUM_KEY_FILE` points pgsodium/vault's bundled `getkey_script` at the + * root key this port already delivers via `docker cp`; without it the script + * generates a fresh random key under `$HOME`, so the cluster's encryption key + * would differ from `db.root_key` on every provision. `POSTGRES_HOST` and + * `POSTGRES_INITDB_ARGS` are deliberately absent: both configure the docker.io + * entrypoint only, and the slim bundle owns its own initdb flags. + */ +function legacyPostgresSlimBootFields(input: { + readonly settings: CliConfig["db"]["settings"]; + readonly rootKey: string; + readonly password: string; + readonly jwtSecret: string; + readonly jwtExpiry: number; + /** Extra `postgres` argv appended after the `[db.settings]` pairs — the shadow's own worker cap. */ + readonly extraArgs: ReadonlyArray; +}): LegacyPostgresBootFields { + return { + env: { + POSTGRES_PASSWORD: input.password, + JWT_SECRET: input.jwtSecret, + JWT_EXP: String(input.jwtExpiry), + PGSODIUM_KEY_FILE: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, + }, + cmd: [...legacyPostgresSettingsToConfigArgs(input.settings), ...input.extraArgs], + secretFiles: [ + { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: input.rootKey }, + { containerPath: LEGACY_POSTGRES_SCHEMA_SQL_PATH, content: LEGACY_POSTGRES_SLIM_SCHEMA_SQL }, + ], + }; +} + /** * Builds the {@link LegacyStartContainerSpec} for the Postgres container — shared by `supabase * start` (always {@link LegacyPostgresStartServiceInput.fromBackup} `undefined`) and `db start`'s @@ -362,6 +523,7 @@ export function legacyBuildPostgresStartContainerSpec( const postgresConfig = legacyPostgresSettingsToPostgresConfig(input.db.settings); const isPg14OrEarlier = input.db.major_version <= 14; const isRestore = input.fromBackup !== undefined; + const isSlim = legacyIsSlimPostgresImage(input.image); const env: Record = { // The constant `"postgres"` literal, matching Go, where `Db.Password` is @@ -383,12 +545,37 @@ export function legacyBuildPostgresStartContainerSpec( ? legacyPostgresEntrypointScriptPg14(postgresConfig) : legacyPostgresEntrypointScriptPg15(postgresConfig); + const bootFields: LegacyPostgresBootFields = isSlim + ? legacyPostgresSlimBootFields({ + settings: input.db.settings, + rootKey: rootKeyValue, + password: LEGACY_POSTGRES_PASSWORD, + jwtSecret: input.jwtSecret, + jwtExpiry: input.jwtExpiry, + extraArgs: [], + }) + : { + env, + entrypoint: "sh", + cmd: ["-c", script], + // The pgsodium root key heredoc/bind is present whenever the ACTUAL entrypoint in use + // embeds it: both `legacyPostgresEntrypointScriptPg15` and + // `legacyPostgresEntrypointScriptRestore` do (Go's `fromBackup` override always re-adds + // its own root-key heredoc, `start.go:147,155`, regardless of major version); only the + // PG<=14 script never references it. + ...(isPg14OrEarlier && !isRestore + ? {} + : { + secretFiles: [ + { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, + ], + }), + }; + return { image: input.image, containerName, - env, - entrypoint: "sh", - cmd: ["-c", script], + ...bootFields, binds: [ `${containerName}:/var/lib/postgresql/data`, // Go's `StartDatabase` (`start.go:163`) appends this bind ONLY on the `fromBackup` branch — @@ -401,20 +588,9 @@ export function legacyBuildPostgresStartContainerSpec( // check is NOT part of `StartDatabase`'s `fromBackup` override, so this stays keyed on // `isPg14OrEarlier` alone, independent of `isRestore`. ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), - // The pgsodium root key heredoc/bind is present whenever the ACTUAL entrypoint in use embeds - // it: both `legacyPostgresEntrypointScriptPg15` and `legacyPostgresEntrypointScriptRestore` do - // (Go's `fromBackup` override always re-adds its own root-key heredoc, `start.go:147,155`, - // regardless of major version); only the PG<=14 script never references it. - ...(isPg14OrEarlier && !isRestore - ? {} - : { - secretFiles: [ - { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, - ], - }), ports: [{ hostPort: String(input.db.port), containerPort: "5432" }], healthcheck: { - test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], + test: isSlim ? LEGACY_POSTGRES_SLIM_HEALTHCHECK_TEST : LEGACY_POSTGRES_HEALTHCHECK_TEST, intervalSeconds: LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS, timeoutSeconds: LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS, retries: LEGACY_POSTGRES_HEALTHCHECK_RETRIES, @@ -429,11 +605,20 @@ export function legacyBuildPostgresStartContainerSpec( /** * Go's `NewContainerConfig("-c", "max_worker_processes=0")` (`CreateShadowDatabase`, * `apps/cli-go/internal/db/diff/diff.go:140`) — disables background workers in the - * shadow database. Not a docker flag: it is spliced into the entrypoint script's own - * `docker-entrypoint.sh postgres -D /etc/postgresql ` line, exactly like every - * other `args` value {@link legacyPostgresEntrypointScriptPg15}/`Pg14` accept. + * shadow database. Declared as argv because that is the shape the slim image + * consumes (trailing container argv, see {@link legacyPostgresSlimBootFields}); + * {@link LEGACY_SHADOW_ENTRYPOINT_ARGS} joins it back for the docker.io + * entrypoint script's own `` splice point. + */ +const LEGACY_SHADOW_ENTRYPOINT_ARGV: ReadonlyArray = ["-c", "max_worker_processes=0"]; + +/** + * {@link LEGACY_SHADOW_ENTRYPOINT_ARGV} as the docker.io entrypoint script sees it: + * not a docker flag, but text spliced into the script's own + * `docker-entrypoint.sh postgres -D /etc/postgresql ` line, exactly like + * every other `args` value {@link legacyPostgresEntrypointScriptPg15}/`Pg14` accept. */ -export const LEGACY_SHADOW_ENTRYPOINT_ARGS = "-c max_worker_processes=0"; +export const LEGACY_SHADOW_ENTRYPOINT_ARGS = LEGACY_SHADOW_ENTRYPOINT_ARGV.join(" "); /** * Input to {@link legacyBuildShadowPostgresContainerSpec} — the subset of @@ -495,6 +680,10 @@ export interface LegacyShadowPostgresContainerSpecInput { * it delivered before `docker start` — via `docker cp` straight into the container * (`container-lifecycle.ts`), same as every other container's `secretFiles`, never a * host temp file. + * - **Under the slim image the whole entrypoint/env/`secretFiles` group comes from + * {@link legacyPostgresSlimBootFields} instead**, with the shadow's worker cap appended to + * the container's trailing argv rather than spliced into a script — everything below stays + * as described. * - **Labels ARE still applied** (merged in by `legacyCreateContainer`, same as every * other container) so `supabase stop`'s label-filtered sweep catches an orphaned shadow * too — Go's `DockerStart` sets `CliProjectLabel`/`composeProjectLabel` unconditionally, @@ -508,6 +697,7 @@ export function legacyBuildShadowPostgresContainerSpec( const rootKeyValue = input.rootKey ?? LEGACY_POSTGRES_DEFAULT_ROOT_KEY; const postgresConfig = legacyPostgresSettingsToPostgresConfig(input.db.settings); const isPg14OrEarlier = input.db.major_version <= 14; + const isSlim = legacyIsSlimPostgresImage(input.image); const env: Record = { POSTGRES_PASSWORD: input.password, @@ -521,25 +711,38 @@ export function legacyBuildShadowPostgresContainerSpec( ? legacyPostgresEntrypointScriptPg14(postgresConfig, LEGACY_SHADOW_ENTRYPOINT_ARGS) : legacyPostgresEntrypointScriptPg15(postgresConfig, LEGACY_SHADOW_ENTRYPOINT_ARGS); + const bootFields: LegacyPostgresBootFields = isSlim + ? legacyPostgresSlimBootFields({ + settings: input.db.settings, + rootKey: rootKeyValue, + password: input.password, + jwtSecret: input.jwtSecret, + jwtExpiry: input.jwtExpiry, + extraArgs: LEGACY_SHADOW_ENTRYPOINT_ARGV, + }) + : { + env, + entrypoint: "sh", + cmd: ["-c", script], + ...(isPg14OrEarlier + ? {} + : { + secretFiles: [ + { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, + ], + }), + }; + return { image: input.image, containerName: "", - env, - entrypoint: "sh", - cmd: ["-c", script], + ...bootFields, binds: [], autoRemove: true, ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), - ...(isPg14OrEarlier - ? {} - : { - secretFiles: [ - { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, - ], - }), ports: [{ hostPort: String(input.shadowPort), containerPort: "5432" }], healthcheck: { - test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], + test: isSlim ? LEGACY_POSTGRES_SLIM_HEALTHCHECK_TEST : LEGACY_POSTGRES_HEALTHCHECK_TEST, intervalSeconds: LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS, timeoutSeconds: LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS, retries: LEGACY_POSTGRES_HEALTHCHECK_RETRIES, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts index a96520a7c7..6ae08a68b3 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts @@ -1,5 +1,5 @@ import type { CliConfig } from "@supabase/config"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { LEGACY_START_DB_RESTORE_SH } from "./templates/db-restore.sh.ts"; import { LEGACY_START_DB_SCHEMA_SQL } from "./templates/db-schema.sql.ts"; @@ -11,6 +11,7 @@ import { legacyBuildPostgresStartContainerSpec, legacyBuildShadowPostgresContainerSpec, legacyPostgresImageVersionTag, + legacyPostgresSettingsToConfigArgs, legacyPostgresSettingsToPostgresConfig, legacyPostgresVersionCompare, type LegacyPostgresStartServiceInput, @@ -19,6 +20,12 @@ import { const POSTGRES_CONFIG_HEADER = "\n# supabase [db.settings] configuration\n"; +const SLIM_POSTGRES_IMAGE = "ghcr.io/supabase/cli/postgres:17.6.1.165"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + function baseDb(overrides: Partial = {}): CliConfig["db"] { return { port: 54322, @@ -229,6 +236,21 @@ describe("legacyBuildPostgresStartContainerSpec", () => { }); }); + test("healthcheck stays the plain pg_isready probe on docker.io even with the slim flag set", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildPostgresStartContainerSpec(baseInput()); + expect(spec.healthcheck?.test).toEqual([ + "CMD", + "pg_isready", + "-U", + "postgres", + "-h", + "127.0.0.1", + "-p", + "5432", + ]); + }); + test("port binding maps the configured db.port to container port 5432", () => { const spec = legacyBuildPostgresStartContainerSpec(baseInput({ db: baseDb({ port: 12345 }) })); expect(spec.ports).toEqual([{ hostPort: "12345", containerPort: "5432" }]); @@ -452,3 +474,142 @@ describe("legacyBuildShadowPostgresContainerSpec", () => { expect(spec.env?.["POSTGRES_PASSWORD"]).toBe("hunter2"); }); }); + +describe("legacyPostgresSettingsToConfigArgs", () => { + test("renders each set value as its own -c key=value pair, unquoted", () => { + expect( + legacyPostgresSettingsToConfigArgs({ + max_connections: 100, + shared_buffers: "128MB", + session_replication_role: "origin", + track_commit_timestamp: true, + }), + ).toEqual([ + "-c", + "max_connections=100", + "-c", + "shared_buffers=128MB", + "-c", + "session_replication_role=origin", + "-c", + "track_commit_timestamp=true", + ]); + }); + + test("emits nothing for empty settings, unlike the conf renderer's header-only output", () => { + expect(legacyPostgresSettingsToConfigArgs({})).toEqual([]); + expect(legacyPostgresSettingsToConfigArgs(undefined)).toEqual([]); + }); +}); + +describe("slim Postgres image spec", () => { + test("keeps the image's own entrypoint and passes [db.settings] as trailing -c argv instead of a heredoc script", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ + image: SLIM_POSTGRES_IMAGE, + db: baseDb({ settings: { max_connections: 120, effective_cache_size: "512MB" } }), + }), + ); + + expect(spec.entrypoint).toBeUndefined(); + expect(spec.cmd).toEqual(["-c", "max_connections=120", "-c", "effective_cache_size=512MB"]); + expect(spec.cmd?.join(" ")).not.toContain("docker-entrypoint.sh"); + }); + + test("stages the schema SQL the bundled migrate.sh hook runs, plus the pgsodium root key it is pointed at by env", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ image: SLIM_POSTGRES_IMAGE, rootKey: "custom-root-key" }), + ); + + expect(spec.env).toEqual({ + POSTGRES_PASSWORD: "postgres", + JWT_SECRET: "super-secret-jwt-token-with-at-least-32-characters-long", + JWT_EXP: "3600", + PGSODIUM_KEY_FILE: "/etc/postgresql-custom/pgsodium_root.key", + }); + expect(spec.secretFiles).toEqual([ + { containerPath: "/etc/postgresql-custom/pgsodium_root.key", content: "custom-root-key" }, + { + containerPath: "/etc/postgresql.schema.sql", + content: `${LEGACY_START_DB_SCHEMA_SQL}\n${LEGACY_START_DB_WEBHOOK_SQL}\n${LEGACY_START_DB_SUPABASE_SQL}\nALTER ROLE postgres WITH SUPERUSER;\nCREATE EXTENSION IF NOT EXISTS vector;\n`, + }, + ]); + }); + + test("leaves the volume bind, published port, healthcheck, and network wiring untouched", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ image: SLIM_POSTGRES_IMAGE, db: baseDb({ port: 12345 }) }), + ); + + expect(spec.binds).toEqual(["supabase_db_myproj:/var/lib/postgresql/data"]); + expect(spec.ports).toEqual([{ hostPort: "12345", containerPort: "5432" }]); + expect(spec.healthcheck).toEqual({ + test: [ + "CMD", + "sh", + "-ec", + 'case "$(cat /proc/1/comm)" in postgres|.postgres-wrapp) pg_isready -U postgres -h 127.0.0.1 -p 5432 ;; *) exit 1 ;; esac', + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }); + expect(spec.networkAliases).toEqual(["db", "db.supabase.internal"]); + expect(spec.restartPolicy).toBe("unless-stopped"); + }); + + test("healthcheck gates on the final postgres process, not the entrypoint shell PID 1 stays during first-boot init", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const dbSpec = legacyBuildPostgresStartContainerSpec(baseInput({ image: SLIM_POSTGRES_IMAGE })); + const shadowSpec = legacyBuildShadowPostgresContainerSpec( + baseShadowInput({ image: SLIM_POSTGRES_IMAGE }), + ); + const expected = [ + "CMD", + "sh", + "-ec", + 'case "$(cat /proc/1/comm)" in postgres|.postgres-wrapp) pg_isready -U postgres -h 127.0.0.1 -p 5432 ;; *) exit 1 ;; esac', + ]; + expect(dbSpec.healthcheck?.test).toEqual(expected); + expect(shadowSpec.healthcheck?.test).toEqual(expected); + }); + + test("appends the shadow's worker cap to the same trailing argv", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildShadowPostgresContainerSpec( + baseShadowInput({ + image: SLIM_POSTGRES_IMAGE, + db: { major_version: 17, settings: { max_connections: 120 } }, + password: "hunter2", + }), + ); + + expect(spec.entrypoint).toBeUndefined(); + expect(spec.cmd).toEqual(["-c", "max_connections=120", "-c", "max_worker_processes=0"]); + expect(spec.env?.["POSTGRES_PASSWORD"]).toBe("hunter2"); + expect(spec.autoRemove).toBe(true); + expect(spec.secretFiles?.map((file) => file.containerPath)).toEqual([ + "/etc/postgresql-custom/pgsodium_root.key", + "/etc/postgresql.schema.sql", + ]); + }); + + // The whole slim path hangs off the resolved ref, so a registry override that + // lands on docker.io keeps the heredoc entrypoint even with the flag set. + test("stays on the docker.io entrypoint when the flag is set but the resolved image is not a slim ref", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildPostgresStartContainerSpec(baseInput()); + expect(spec.entrypoint).toBe("sh"); + expect(spec.cmd?.[1]).toContain("exec docker-entrypoint.sh"); + }); + + test("stays on the docker.io entrypoint for a slim ref while the flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + const spec = legacyBuildPostgresStartContainerSpec(baseInput({ image: SLIM_POSTGRES_IMAGE })); + expect(spec.entrypoint).toBe("sh"); + expect(spec.cmd?.[1]).toContain("exec docker-entrypoint.sh"); + }); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts new file mode 100644 index 0000000000..fdef2843d1 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts @@ -0,0 +1,49 @@ +/** + * Slim-image runtime contracts that differ from docker.io. Spec builders switch + * on {@link usesSlimImageRuntime} so flag-off stays byte-identical even if a + * caller passes a ghcr-shaped override. + * + * Docker CLI `--health-cmd` is always stored as `CMD-SHELL` and executed with + * `/bin/sh -c` (`docker-create-args.ts`). Distroless images with no `/bin/sh` + * (auth, storage, studio, pg-meta, edge-runtime) therefore cannot carry a + * Docker healthcheck through this CLI — omit it and let + * `legacyCheckContainerReady` treat `Running` as ready, the same as PostgREST. + * Elixir/busybox images (realtime, analytics) and Vector do ship `/bin/sh` plus + * a wget applet, so they keep an exec-form probe that the CLI quotes into + * CMD-SHELL. + */ + +import { usesSlimImageRuntime } from "../../../shared/services/slim-images.ts"; + +/** {@link usesSlimImageRuntime} under the mandatory `legacy` export prefix. */ +export function legacyUsesSlimRuntime(image: string): boolean { + return usesSlimImageRuntime(image); +} + +export const LEGACY_SLIM_BUSYBOX = "/bin/busybox"; + +export function legacySlimBusyboxWgetHealthcheck( + url: string, + opts: { readonly header?: string; readonly startPeriodSeconds?: number } = {}, +): { + readonly test: ReadonlyArray; + readonly intervalSeconds: number; + readonly timeoutSeconds: number; + readonly retries: number; + readonly startPeriodSeconds?: number; +} { + const test = ["CMD", LEGACY_SLIM_BUSYBOX, "wget", "-q", "--spider"]; + if (opts.header !== undefined) { + test.push("--header", opts.header); + } + test.push(url); + return { + test, + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + ...(opts.startPeriodSeconds === undefined + ? {} + : { startPeriodSeconds: opts.startPeriodSeconds }), + }; +} diff --git a/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.unit.test.ts new file mode 100644 index 0000000000..3ba0844a3c --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.unit.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + LEGACY_SLIM_BUSYBOX, + legacySlimBusyboxWgetHealthcheck, + legacyUsesSlimRuntime, +} from "./slim-runtime.ts"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("legacyUsesSlimRuntime", () => { + it("requires the flag and a slim ref", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + expect(legacyUsesSlimRuntime("ghcr.io/supabase/cli/storage:v1.70.3")).toBe(true); + expect(legacyUsesSlimRuntime("supabase/storage-api:v1.70.3")).toBe(false); + }); +}); + +describe("legacySlimBusyboxWgetHealthcheck", () => { + it("invokes busybox wget --spider", () => { + const check = legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:4000/health", { + header: "Host:realtime-dev", + startPeriodSeconds: 10, + }); + expect(check.test).toEqual([ + "CMD", + LEGACY_SLIM_BUSYBOX, + "wget", + "-q", + "--spider", + "--header", + "Host:realtime-dev", + "http://127.0.0.1:4000/health", + ]); + expect(check.startPeriodSeconds).toBe(10); + }); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts index 19bfb04569..8f166320e2 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -8,13 +8,20 @@ * Go's `StartDatabase` now only has one TS home to update. * * Exact Go call order: pre-create volume-existence probe (+ the `fromBackup`-on-an-existing-volume - * guard) -> image resolve + network ensure (Go's `DockerStart` resolves the image, THEN creates - * the network, both strictly ahead of container create — `docker.go:363-386` — so NEITHER one - * ever runs on a request the volume guard above already rejected) -> Postgres container - * create+start -> health wait (swallowed ONLY when `fromBackup` is set — "restoring a large - * backup may take longer than 2 minutes") -> the fresh-volume `SetupLocalDatabase`-equivalent - * pipeline (skipped IN FULL when `fromBackup` is set) -> `initCurrentBranch`, unconditionally (the - * LAST line of `StartDatabase`, reached on every path that doesn't already return/fail above). + * guard) -> image resolve (+ a TS-only slim-image reused-volume access guard, see below) + + * network ensure (Go's `DockerStart` resolves the image, THEN creates the network, both strictly + * ahead of container create — `docker.go:363-386` — so NEITHER one ever runs on a request the + * volume guard above already rejected) -> Postgres container create+start -> health wait + * (swallowed ONLY when `fromBackup` is set — "restoring a large backup may take longer than 2 + * minutes") -> the fresh-volume `SetupLocalDatabase`-equivalent pipeline (skipped IN FULL when + * `fromBackup` is set) -> `initCurrentBranch`, unconditionally (the LAST line of `StartDatabase`, + * reached on every path that doesn't already return/fail above). + * + * The slim-image volume-access guard has no Go equivalent (`SUPABASE_USE_SLIM_IMAGES` is a TS-only + * feature): on an existing volume, once the image is resolved, a cheap `docker run` probe checks + * whether a slim (non-root `65532`) image can actually read and write PGDATA before any container is + * created — a docker.io-initialized volume's `700`-mode dirs otherwise crash-loop the slim + * process until the health check times out with no useful message. * * Deliberately has ZERO knowledge of `--ignore-health-check` — matching Go exactly: that flag is * `internal/start/start.go`'s `Run()`'s own concern, entirely OUTSIDE `StartDatabase` (Go's @@ -66,6 +73,7 @@ import type { LegacyDockerRun } from "../legacy-docker-run.service.ts"; import { legacyEnsureNetwork, legacyCreateContainer, + legacyIsVolumeAccessibleToImage, legacyVolumeExists, LEGACY_COMPOSE_PROJECT_LABEL, type LegacyContainerCreateError, @@ -95,6 +103,7 @@ import { } from "./messages.ts"; import { legacyBuildPostgresStartContainerSpec, + legacyIsSlimPostgresImage, type LegacyPostgresStartServiceInput, } from "./postgres.service.ts"; @@ -121,11 +130,59 @@ export class LegacyStartBackupVolumeExistsError extends Data.TaggedError( } } +/** + * `--from-backup` reached a slim Postgres image. The docker.io restore path is entirely a + * property of that image's entrypoint — `docker-entrypoint.sh` running the restore script + * this port heredocs into `/docker-entrypoint-initdb.d/migrate.sh` against the + * `/etc/backup.sql` bind (`postgres.service.ts`'s restore entrypoint variant) — and the slim + * build ships neither seam, so a restore would silently start an empty cluster instead. + * Refused before any container is created. Exported only so the exhaustive actionability + * guard can inspect its declaration. + */ +export class LegacySlimImagesBackupUnsupportedError extends Data.TaggedError( + "LegacySlimImagesBackupUnsupportedError", +)<{ + readonly message: string; + readonly suggestion?: string; +}> { + // The remediation is to change what the caller passed in — the env flag, not the config file + // — and the error carries the concrete instruction, so this matches `provideFlags` rather + // than the suggestion-free `invalidInput`. + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * An existing db volume was initialized by a docker.io Postgres image (PGDATA owned by that + * image's `postgres` uid, `700`-mode dirs) and is being reused under the slim image, whose + * `65532` runtime user cannot read it — the slim process would otherwise crash-loop until the + * health check times out with no useful message. Detected by a pre-create access probe (read + write — + * Postgres must write under PGDATA) + * (`legacyIsVolumeAccessibleToImage`), reached only when the resolved image is slim AND the volume + * already existed. Exported only so the exhaustive actionability guard can inspect its + * declaration. + */ +export class LegacySlimImageVolumeInaccessibleError extends Data.TaggedError( + "LegacySlimImageVolumeInaccessibleError", +)<{ + readonly message: string; + readonly suggestion?: string; +}> { + // Same shape as `LegacySlimImagesBackupUnsupportedError`: the fix is to change what the + // caller passed in — reset the volume or unset the env flag — not a suggestion-free default. + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** Every failure {@link legacyStartDatabase} itself can produce, independent of the caller's own `E`. */ export type LegacyStartDatabaseError = | LegacyNetworkCreateError | LegacyVolumeInspectError | LegacyStartBackupVolumeExistsError + | LegacySlimImagesBackupUnsupportedError + | LegacySlimImageVolumeInaccessibleError | LegacyVolumeCreateError | LegacyContainerCreateError | LegacyContainerStartError @@ -229,6 +286,38 @@ export const legacyStartDatabase = ( const resolvedPostgresImage = yield* input.resolvePostgresImage; + // Gated on the resolved ref, not the env flag alone: a registry override can still land this + // run on a docker.io image, which restores fine. + if (fromBackup !== undefined && legacyIsSlimPostgresImage(resolvedPostgresImage)) { + return yield* Effect.fail( + new LegacySlimImagesBackupUnsupportedError({ + message: "--from-backup is not supported with SUPABASE_USE_SLIM_IMAGES", + suggestion: "Unset SUPABASE_USE_SLIM_IMAGES to restore from a backup.", + }), + ); + } + + // A reused volume's PGDATA ownership is a property of whichever image initialized it, not + // of the image resolved for THIS run — a docker.io-initialized volume's `700`-mode dirs + // block the slim image's non-root user. Only reachable on an existing volume; a fresh one + // has no pre-existing ownership to conflict with. + if (!isFreshVolume && legacyIsSlimPostgresImage(resolvedPostgresImage)) { + const accessible = yield* legacyIsVolumeAccessibleToImage( + spawner, + resolvedPostgresImage, + input.dbContainerId, + ); + if (!accessible) { + return yield* Effect.fail( + new LegacySlimImageVolumeInaccessibleError({ + message: + "the existing database volume was initialized by a non-slim postgres image and is not readable and writable by the slim image's user", + suggestion: `Run ${legacyAqua("supabase stop --no-backup")} to reset the local database, or unset SUPABASE_USE_SLIM_IMAGES.`, + }), + ); + } + } + // Go's `DockerStart` (`docker.go:363-386`): image resolve, THEN network create, both // strictly ahead of container create — hoisted here to run ONCE per `start` run instead of // once per container (Go's own repeated per-container call is a no-op after the first, see diff --git a/apps/cli/src/legacy/shared/legacy-db-image.ts b/apps/cli/src/legacy/shared/legacy-db-image.ts index c7b7ec3690..369755780b 100644 --- a/apps/cli/src/legacy/shared/legacy-db-image.ts +++ b/apps/cli/src/legacy/shared/legacy-db-image.ts @@ -11,7 +11,10 @@ import { dockerfileServiceImage } from "../../shared/services/dockerfile-images. * into `config.Images`, so the TS port tracks Dependabot bumps in that source. */ -const LEGACY_PG_IMAGE = dockerfileServiceImage("pg"); +// Read per call, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is +// observed by the resolver (and by tests that stub the env). +const legacyPgImage = () => dockerfileServiceImage("pg"); +// Major-version fallbacks and the OrioleDB tags below have no slim build. const LEGACY_PG14 = "supabase/postgres:14.1.0.89"; const LEGACY_PG15 = "supabase/postgres:15.8.1.085"; @@ -77,7 +80,7 @@ export const legacyResolveDbImage = Effect.fnUntraced(function* ( ? `supabase/postgres:${orioledbVersion}-orioledb` : `supabase/postgres:orioledb-${orioledbVersion}`; } - let image = LEGACY_PG_IMAGE; + let image = legacyPgImage(); switch (majorVersion) { case 13: image = LEGACY_PG15; @@ -101,7 +104,7 @@ export const legacyResolveDbImage = Effect.fnUntraced(function* ( const colon = image.indexOf(":"); const currentTag = colon >= 0 ? image.slice(colon + 1) : image; if (versionCompare(currentTag, "15.1.0.55") >= 0) { - image = replaceImageTag(LEGACY_PG_IMAGE, pinned); + image = replaceImageTag(image, pinned); } } } diff --git a/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts index f74184da96..190202036a 100644 --- a/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts @@ -1,15 +1,22 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Path } from "effect"; +import { afterEach, vi } from "vitest"; import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; import { legacyResolveDbImage } from "./legacy-db-image.ts"; const withTemp = () => mkdtempSync(join(tmpdir(), "legacy-db-image-")); +const writePin = (workdir: string, pinned: string) => { + const dir = join(workdir, "supabase", ".temp"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "postgres-version"), pinned); +}; + const resolve = (workdir: string, majorVersion: number, orioledbVersion?: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -47,4 +54,30 @@ describe("legacyResolveDbImage", () => { rmSync(dir, { recursive: true, force: true }); }); }); + + describe("pinned version with the slim-images flag on", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.effect("keeps a 13/14/15 fallback on docker.io, not the slim registry", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = withTemp(); + writePin(dir, "15.8.1.100"); + return Effect.gen(function* () { + expect(yield* resolve(dir, 15)).toBe("supabase/postgres:15.8.1.100"); + rmSync(dir, { recursive: true, force: true }); + }); + }); + + it.effect("rewrites the default major's pin to the slim registry", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = withTemp(); + writePin(dir, "17.9.9.999"); + return Effect.gen(function* () { + expect(yield* resolve(dir, 17)).toBe("ghcr.io/supabase/cli/postgres:17.9.9.999"); + rmSync(dir, { recursive: true, force: true }); + }); + }); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-docker-registry.ts b/apps/cli/src/legacy/shared/legacy-docker-registry.ts index eda19ae402..04e029ed55 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-registry.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-registry.ts @@ -12,7 +12,16 @@ * When no registry override is configured, callers that can retry pulls should * use `legacyGetRegistryImageUrlCandidates`: ECR stays the fast default, with * GHCR and the source image as fallbacks for transient registry throttling. + * + * Slim images (`isSlimImageRef`) skip every rewrite below and pull from where + * they exist: both helpers key their rewrite on an image's LAST path segment, + * which would turn `ghcr.io/supabase/cli/postgres:…` into the unrelated + * non-slim `…/supabase/postgres:…` mirror. There is no mirror to redirect + * slim refs to, hence `SUPABASE_INTERNAL_IMAGE_REGISTRY` does not apply to + * them either. */ +import { isSlimImageRef } from "../../shared/services/slim-images.ts"; + const LEGACY_INTERNAL_IMAGE_REGISTRY_ENV = "SUPABASE_INTERNAL_IMAGE_REGISTRY"; const DEFAULT_REGISTRY = "public.ecr.aws"; const DEFAULT_SUPABASE_REGISTRY = `${DEFAULT_REGISTRY}/supabase`; @@ -57,6 +66,9 @@ export function legacyGetRegistryImageUrl( imageName: string, projectEnvValues?: Readonly>, ): string { + if (isSlimImageRef(imageName)) { + return imageName; + } const registry = legacyGetRegistry(projectEnvValues); if (registry === DOCKER_HUB_REGISTRY) { return imageName; @@ -68,6 +80,10 @@ export function legacyGetRegistryImageUrlCandidates( imageName: string, projectEnvValues?: Readonly>, ): ReadonlyArray { + if (isSlimImageRef(imageName)) { + return [imageName]; + } + if (legacyGetRegistryOverride(projectEnvValues) !== undefined) { return [legacyGetRegistryImageUrl(imageName, projectEnvValues)]; } diff --git a/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts index b1c81c8ee2..d44acc32e5 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts @@ -122,4 +122,52 @@ describe("legacyGetRegistryImageUrl", () => { ), ).toBe("merged.example/supabase/pg_prove:3.36"); }); + + // Slim images are published only under `ghcr.io/supabase/cli`. Rewriting them + // by last path segment would silently pull the unrelated non-slim mirror, and + // no mirror of them exists for a registry override to point at. + const SLIM_IMAGE = "ghcr.io/supabase/cli/postgres:17.6.1.165"; + + it("leaves a slim image unrewritten, whatever the registry override says", () => { + for (const registry of [undefined, "public.ecr.aws", "docker.io", "my.mirror.example"]) { + expect(withRegistry(registry, () => legacyGetRegistryImageUrl(SLIM_IMAGE))).toBe(SLIM_IMAGE); + } + expect( + withRegistry(undefined, () => + legacyGetRegistryImageUrl(SLIM_IMAGE, { + SUPABASE_INTERNAL_IMAGE_REGISTRY: "my.mirror.example", + }), + ), + ).toBe(SLIM_IMAGE); + }); + + it("plans a single pull candidate for a slim image", () => { + for (const registry of [undefined, "public.ecr.aws", "docker.io", "my.mirror.example"]) { + expect(withRegistry(registry, () => legacyGetRegistryImageUrlCandidates(SLIM_IMAGE))).toEqual( + [SLIM_IMAGE], + ); + } + expect( + withRegistry(undefined, () => + legacyGetRegistryImageUrlCandidates(SLIM_IMAGE, { + SUPABASE_INTERNAL_IMAGE_REGISTRY: "my.mirror.example", + }), + ), + ).toEqual([SLIM_IMAGE]); + }); + + it("still rewrites the non-slim ghcr.io/supabase namespace", () => { + expect( + withRegistry("docker.io", () => legacyGetRegistryImageUrl("ghcr.io/supabase/postgres:17.6")), + ).toBe("ghcr.io/supabase/postgres:17.6"); + expect( + withRegistry(undefined, () => + legacyGetRegistryImageUrlCandidates("ghcr.io/supabase/postgres:17.6"), + ), + ).toEqual([ + "public.ecr.aws/supabase/postgres:17.6", + "ghcr.io/supabase/postgres:17.6", + "supabase/postgres:17.6", + ]); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts index 6523ac2a7d..6670386345 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts @@ -1,5 +1,9 @@ import { Effect, type FileSystem, type Path } from "effect"; -import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; +import { DENO1_EDGE_RUNTIME_VERSION } from "../../shared/functions/functions.shared.ts"; +import { + dockerfileServiceImage, + dockerfileServiceImageRaw, +} from "../../shared/services/dockerfile-images.ts"; /** * Resolves the edge-runtime Docker image the way Go's `config.Load` does @@ -12,9 +16,14 @@ import { dockerfileServiceImage } from "../../shared/services/dockerfile-images. * image instead (default `deno_version = 2` keeps the Dockerfile image). */ -export const LEGACY_EDGE_RUNTIME_IMAGE = dockerfileServiceImage("edgeruntime"); -// `deno1` (`pkg/config/constants.go:15`) — used when `deno_version = 1`. -const LEGACY_EDGE_RUNTIME_DENO1_IMAGE = "supabase/edge-runtime:v1.68.4"; +// Read per call, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is +// observed by the resolver (and by tests that stub the env). +export const legacyEdgeRuntimeImage = () => dockerfileServiceImage("edgeruntime"); +// `deno1` (`pkg/config/constants.go:15`) — used when `deno_version = 1`. No slim +// build exists for it, so it stays on docker.io regardless of the flag — the +// same exception `edgeRuntimeImage` (`shared/functions/functions.shared.ts`) +// applies for the functions Docker paths reading the SAME pin file. +const LEGACY_EDGE_RUNTIME_DENO1_IMAGE = `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`; /** `pkg/config/utils.go:81` — replace everything after the first `:` with `tag`. */ function replaceImageTag(image: string, tag: string): string { @@ -22,30 +31,58 @@ function replaceImageTag(image: string, tag: string): string { return image.slice(0, index + 1) + tag.trim(); } -/** - * Resolve the edge-runtime image, honoring the pinned tag in - * `supabase/.temp/edge-runtime-version` and the `deno_version` selector - * (default 2 → Dockerfile image; 1 → `deno1`). The version pin is applied first - * (Go's `Load`), then `deno_version = 1` overrides to `deno1` (Go's validate - * pass). - */ -export const legacyResolveEdgeRuntimeImage = Effect.fnUntraced(function* ( +const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( + baseImage: string, fs: FileSystem.FileSystem, path: Path.Path, workdir: string, denoVersion: number, ) { - let image = LEGACY_EDGE_RUNTIME_IMAGE; + let image = baseImage; const versionPath = path.join(workdir, "supabase", ".temp", "edge-runtime-version"); const pinned = yield* fs.readFileString(versionPath).pipe( Effect.map((s) => s.trim()), Effect.orElseSucceed(() => ""), ); if (pinned.length > 0) { - image = replaceImageTag(LEGACY_EDGE_RUNTIME_IMAGE, pinned); + // A pin of the deno1 tag (e.g. left in .temp by an earlier deno_version = 1 + // run) resolves docker.io whatever selected it: no slim build of that tag + // exists, so tag-swapping it onto a slim base would yield an unpullable ref. + image = + pinned === DENO1_EDGE_RUNTIME_VERSION + ? LEGACY_EDGE_RUNTIME_DENO1_IMAGE + : replaceImageTag(baseImage, pinned); } if (denoVersion === 1) { image = LEGACY_EDGE_RUNTIME_DENO1_IMAGE; } return image; }); + +/** + * Resolve the edge-runtime image, honoring the pinned tag in + * `supabase/.temp/edge-runtime-version` and the `deno_version` selector + * (default 2 → Dockerfile image; 1 → `deno1`). The version pin is applied first + * (Go's `Load`), then `deno_version = 1` overrides to `deno1` (Go's validate + * pass). + */ +export const legacyResolveEdgeRuntimeImage = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + denoVersion: number, +) => resolveEdgeRuntimeImage(legacyEdgeRuntimeImage(), fs, path, workdir, denoVersion); + +/** + * Same resolution pinned to docker.io, for callers that replace the image + * entrypoint with a shell. The slim edge-runtime image is distroless: its only + * executables are `/usr/bin/edge-runtime` and its wrapper, so `sh -c …` cannot + * run there at all — the same locked exception the `deno1` tag already carries. + */ +export const legacyResolveEdgeRuntimeShellImage = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + denoVersion: number, +) => + resolveEdgeRuntimeImage(dockerfileServiceImageRaw("edgeruntime"), fs, path, workdir, denoVersion); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts index 65a850247b..0d93470c37 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts @@ -2,11 +2,15 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; +import { afterEach, describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Path } from "effect"; +import { vi } from "vitest"; import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; -import { legacyResolveEdgeRuntimeImage } from "./legacy-edge-runtime-image.ts"; +import { + legacyResolveEdgeRuntimeImage, + legacyResolveEdgeRuntimeShellImage, +} from "./legacy-edge-runtime-image.ts"; const resolve = (workdir: string, denoVersion: number) => Effect.gen(function* () { @@ -15,6 +19,13 @@ const resolve = (workdir: string, denoVersion: number) => return yield* legacyResolveEdgeRuntimeImage(fs, path, workdir, denoVersion); }).pipe(Effect.provide(BunServices.layer)); +const resolveShell = (workdir: string, denoVersion: number) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyResolveEdgeRuntimeShellImage(fs, path, workdir, denoVersion); + }).pipe(Effect.provide(BunServices.layer)); + describe("legacyResolveEdgeRuntimeImage", () => { it.effect("returns the edge-runtime image from the Dockerfile when nothing is pinned", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); @@ -28,6 +39,18 @@ describe("legacyResolveEdgeRuntimeImage", () => { ); }); + it.effect("resolves the shell-pinned variant to the same image while the flag is off", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); + return resolveShell(dir, 2).pipe( + Effect.tap((image) => + Effect.sync(() => { + expect(image).toBe(dockerfileServiceImage("edgeruntime")); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("honors the pinned tag in .temp/edge-runtime-version", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); @@ -53,4 +76,55 @@ describe("legacyResolveEdgeRuntimeImage", () => { ), ); }); + + describe("with the slim-images flag on", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.effect("resolves a normal pin onto the slim base", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); + mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(dir, "supabase", ".temp", "edge-runtime-version"), "v9.9.9\n"); + return resolve(dir, 2).pipe( + Effect.tap((image) => + Effect.sync(() => { + expect(image).toBe("ghcr.io/supabase/cli/edge-runtime:v9.9.9"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("keeps the shell-pinned resolution on docker.io, pin included", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); + mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(dir, "supabase", ".temp", "edge-runtime-version"), "v9.9.9\n"); + return resolveShell(dir, 2).pipe( + Effect.tap((image) => + Effect.sync(() => { + expect(image).toBe("supabase/edge-runtime:v9.9.9"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("keeps a deno1-tag pin on docker.io, where that tag exists", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); + mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(dir, "supabase", ".temp", "edge-runtime-version"), "v1.68.4\n"); + return resolve(dir, 2).pipe( + Effect.tap((image) => + Effect.sync(() => { + expect(image).toBe("supabase/edge-runtime:v1.68.4"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts index 41bdcd6ba4..282a1824b0 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, Exit, Layer, Option } from "effect"; +import { vi } from "vitest"; import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; @@ -192,6 +193,28 @@ describe("legacyEdgeRuntimeScriptLayer sentinel handling", () => { }, ); + it.effect("keeps the runner on the docker.io image with the slim-images flag on", () => { + // The runner replaces the entrypoint with `sh -c `; the distroless + // slim image ships no shell, so it must never be selected here. + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const { layer, docker } = setup({ + exitCode: 1, + stdout: "", + stderr: "main worker has been destroyed\n", + }); + return runScript().pipe( + Effect.tap(() => + Effect.sync(() => { + expect(docker.lastOpts?.entrypoint).toStrictEqual(Option.some("sh")); + expect(docker.lastOpts?.image).not.toContain("ghcr.io/supabase/cli/"); + expect(docker.lastOpts?.image).toContain("edge-runtime:"); + }), + ), + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), + ); + }); + it.effect( "disables SELinux label separation so the container can read CLI-written workspace files", () => { diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts index 17e1a65d6f..9146fd38e5 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts @@ -7,7 +7,7 @@ import { LegacyCliSettings } from "../config/legacy-cli-settings.service.ts"; import { legacyReadDbToml } from "./legacy-db-config.toml-read.ts"; import { legacyGetRegistryImageUrl } from "./legacy-docker-registry.ts"; import { LegacyDockerRun } from "./legacy-docker-run.service.ts"; -import { legacyResolveEdgeRuntimeImage } from "./legacy-edge-runtime-image.ts"; +import { legacyResolveEdgeRuntimeShellImage } from "./legacy-edge-runtime-image.ts"; import { LegacyEdgeRuntimeScriptError } from "./legacy-edge-runtime-script.errors.ts"; import { LEGACY_EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL, @@ -101,8 +101,10 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( (error) => new LegacyEdgeRuntimeScriptError({ message: error.message }), ), )).denoVersion; + // Shell-pinned resolution: this runner delivers `index.ts` through an + // `sh -c` here-document, which the distroless slim image cannot run. const registryImage = legacyGetRegistryImageUrl( - yield* legacyResolveEdgeRuntimeImage(fs, path, workdir, denoVersion), + yield* legacyResolveEdgeRuntimeShellImage(fs, path, workdir, denoVersion), ); const port = yield* allocateFreeHostPort; const startCmd = legacyBuildEdgeRuntimeStartCmd({ port, debug }).join(" "); diff --git a/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts index 3194df9546..bfa8ea8785 100644 --- a/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts +++ b/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts @@ -3,6 +3,7 @@ import { Effect, Option } from "effect"; import { LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { legacyIsSlimPostgresImage } from "./db-bootstrap/postgres.service.ts"; import { legacyGetRegistryImageUrl } from "./legacy-docker-registry.ts"; import { LegacyDockerRun } from "./legacy-docker-run.service.ts"; @@ -64,10 +65,18 @@ export const legacyStreamPgDump = Effect.fnUntraced(function* (params: { : { _tag: "host" as const }; const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; + // The docker.io entrypoint execs non-`postgres` argv directly, so a plain `["bash", "-c", + // script, "--"]` cmd runs under the image's own entrypoint. The slim image's `entry.sh` + // instead always initdb's and execs `postgres` with whatever argv it is given, so this + // one-shot job needs its entrypoint overridden to the shell the cmd expects. + const isSlim = legacyIsSlimPostgresImage(params.image); + return yield* docker.runStream( { image: legacyGetRegistryImageUrl(params.image), - cmd: ["bash", "-c", params.script, "--"], + ...(isSlim + ? { entrypoint: Option.some("bash"), cmd: ["-c", params.script, "--"] } + : { cmd: ["bash", "-c", params.script, "--"] }), env: params.env, binds: [], workingDir: Option.none(), diff --git a/apps/cli/src/legacy/shared/legacy-pg-dump.run.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.run.unit.test.ts new file mode 100644 index 0000000000..2e7882e63e --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-pg-dump.run.unit.test.ts @@ -0,0 +1,83 @@ +import { Effect, Layer, Option } from "effect"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { LegacyDockerRun, type LegacyDockerRunOpts } from "./legacy-docker-run.service.ts"; +import { legacyStreamPgDump } from "./legacy-pg-dump.run.ts"; + +const DOCKER_IO_IMAGE = "supabase/postgres:17.4.1.030"; +const SLIM_IMAGE = "ghcr.io/supabase/cli/postgres:17.6.1.165"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function mockDockerRun() { + const calls: LegacyDockerRunOpts[] = []; + const layer = Layer.succeed(LegacyDockerRun, { + run: () => Effect.succeed(0), + runCapture: () => Effect.succeed({ exitCode: 0, stdout: new Uint8Array(0), stderr: "" }), + runStream: (opts) => + Effect.sync(() => { + calls.push(opts); + return { exitCode: 0, stderr: "" }; + }), + }); + return { + layer, + get lastOpts() { + return calls[calls.length - 1]; + }, + }; +} + +const runtimeInfoLayer = Layer.succeed(RuntimeInfo, { + cwd: "/work/project", + platform: "linux", + arch: "x64", + homeDir: "/home/user", + execPath: "/usr/bin/supabase", + pid: 1234, +}); + +function runStreamPgDump(image: string): LegacyDockerRunOpts { + const docker = mockDockerRun(); + const layer = Layer.mergeAll( + docker.layer, + runtimeInfoLayer, + Layer.succeed(LegacyNetworkIdFlag, Option.none()), + ); + Effect.runSync( + legacyStreamPgDump({ + image, + script: "pg_dump", + env: {}, + onStdout: () => Effect.void, + }).pipe(Effect.provide(layer)), + ); + const opts = docker.lastOpts; + if (opts === undefined) throw new Error("docker.runStream was never called"); + return opts; +} + +describe("legacyStreamPgDump entrypoint wiring", () => { + test("docker.io: keeps the image's own entrypoint, running bash under it", () => { + const opts = runStreamPgDump(DOCKER_IO_IMAGE); + expect(opts.entrypoint).toBeUndefined(); + expect(opts.cmd).toEqual(["bash", "-c", "pg_dump", "--"]); + }); + + test("SUPABASE_USE_SLIM_IMAGES unset: a ghcr.io-shaped image still keeps the docker.io cmd shape (flag-off byte-identity)", () => { + const opts = runStreamPgDump(SLIM_IMAGE); + expect(opts.entrypoint).toBeUndefined(); + expect(opts.cmd).toEqual(["bash", "-c", "pg_dump", "--"]); + }); + + test("slim image + flag on: overrides the entrypoint to bash, since entry.sh would otherwise initdb and exec the dump script as postgres argv", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const opts = runStreamPgDump(SLIM_IMAGE); + expect(Option.getOrUndefined(opts.entrypoint ?? Option.none())).toBe("bash"); + expect(opts.cmd).toEqual(["-c", "pg_dump", "--"]); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-status-values.ts b/apps/cli/src/legacy/shared/legacy-status-values.ts index 744906721a..c51f2ac46a 100644 --- a/apps/cli/src/legacy/shared/legacy-status-values.ts +++ b/apps/cli/src/legacy/shared/legacy-status-values.ts @@ -1,6 +1,6 @@ import type { CliConfig } from "@supabase/config"; -import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; +import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; import { legacyServiceContainerIds } from "./legacy-docker-ids.ts"; import { legacyEnvOverrideBool, @@ -188,19 +188,22 @@ export function legacyShortContainerImageName(imageName: string): string { // Default image short names `--exclude` also matches against, // one per gated service. Sourced from the same -// embedded Dockerfile manifest Go parses (`dockerfileServiceImage`), so a version bump +// embedded Dockerfile manifest Go parses (`dockerfileServiceImageRaw`), so a version bump // there is picked up automatically. Pinned-version substitution // (`legacy-db-image.ts`'s `replaceImageTag`) only ever rewrites the portion after the // first `:`, which `legacyShortContainerImageName` discards — so these are invariant to // version pinning and no `.temp/-version` file needs to be read here. -const KONG_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("kong")); -const POSTGREST_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("postgrest")); -const STUDIO_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("studio")); -const GOTRUE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("gotrue")); -const MAILPIT_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("mailpit")); -const STORAGE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("storage")); +// They read the RAW manifest so `SUPABASE_USE_SLIM_IMAGES` cannot shift them: +// these names are the established `--exclude`/status-key contract (`gotrue`, +// `storage-api`), while slim refs would report `supabase/cli/auth` etc. +const KONG_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImageRaw("kong")); +const POSTGREST_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImageRaw("postgrest")); +const STUDIO_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImageRaw("studio")); +const GOTRUE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImageRaw("gotrue")); +const MAILPIT_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImageRaw("mailpit")); +const STORAGE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImageRaw("storage")); const EDGE_RUNTIME_IMAGE_NAME = legacyShortContainerImageName( - dockerfileServiceImage("edgeruntime"), + dockerfileServiceImageRaw("edgeruntime"), ); export interface LegacyStatusValuesResult { diff --git a/apps/cli/src/legacy/shared/legacy-status-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-status-values.unit.test.ts index d32f6bb80a..99d596b4b8 100644 --- a/apps/cli/src/legacy/shared/legacy-status-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-status-values.unit.test.ts @@ -1,6 +1,6 @@ import { CliConfigSchema, type CliConfig } from "@supabase/config"; import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { legacyShortContainerImageName, @@ -766,6 +766,43 @@ describe("legacyStatusValues", () => { }); }); +// `--exclude` short names are the established contract, so they must stay on the +// docker.io repo names even when the stack itself runs slim `ghcr.io/supabase/cli` +// images. Re-imports the module so the flag is in effect while its +// image-name constants are built. +describe("--exclude image short names under SUPABASE_USE_SLIM_IMAGES", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("keeps matching the docker.io short names", async () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + vi.resetModules(); + const slimModule = await import("./legacy-status-values.ts"); + + for (const [excluded, omitted] of [ + ["gotrue", "ANON_KEY"], + ["storage-api", "STORAGE_S3_URL"], + ["kong", "API_URL"], + ["mailpit", "MAILPIT_URL"], + ["postgrest", "REST_URL"], + ["studio", "STUDIO_URL"], + ["edge-runtime", "FUNCTIONS_URL"], + ] as const) { + const { values } = slimModule.legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [excluded], + NO_OVERRIDES, + WORKDIR, + ); + expect(values[omitted], `--exclude ${excluded}`).toBeUndefined(); + } + }); +}); + describe("legacyShortContainerImageName", () => { it("extracts the repo name between the first slash and the last colon", () => { expect(legacyShortContainerImageName("supabase/storage-api:v1.61.9")).toBe("storage-api"); diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 999fc633d1..24cfa2bc5d 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -9,13 +9,10 @@ import { Effect, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { spawnContainerCli } from "../../legacy/shared/legacy-container-cli.ts"; import { legacyMakeDockerImageResolver } from "../../legacy/shared/legacy-docker-image-resolve.ts"; +import { DENO1_EDGE_RUNTIME_VERSION } from "./functions.shared.ts"; const INVALID_PROJECT_ID = /[^a-zA-Z0-9_.-]+/g; const MAX_PROJECT_ID_LENGTH = 40; -// Go's `deno1` image tag (`pkg/config/constants.go:15`, -// `supabase/edge-runtime:v1.68.4`) — a full tag, since tags flow verbatim -// into `edgeRuntimeImage` (`functions.shared.ts`) with no `v` synthesis. -const DENO1_EDGE_RUNTIME_VERSION = "v1.68.4"; export function toSlash(pathname: string) { return pathname.replaceAll("\\", "/"); diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 63f740c849..76f8e9cca1 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -27,8 +27,15 @@ export const FUNCTIONS_BUNDLER_MUTEX_GROUP = ["use-api", "use-docker", "legacy-b // reads the same source) — sourced from there rather than `@supabase/stack`'s // independently-maintained catalog, so a Dockerfile pin bump can never drift // from what the `functions` Docker paths resolve. -const DEFAULT_EDGE_RUNTIME_IMAGE = dockerfileServiceImage("edgeruntime"); -const DEFAULT_EDGE_RUNTIME_TAG = DEFAULT_EDGE_RUNTIME_IMAGE.split(":")[1] ?? ""; +// Read per call, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is +// observed by every resolution (and by tests that stub the env). +const defaultEdgeRuntimeImage = () => dockerfileServiceImage("edgeruntime"); + +// Go's `deno1` image tag (`pkg/config/constants.go:15`, +// `supabase/edge-runtime:v1.68.4`) — a full tag, since tags flow verbatim +// into `edgeRuntimeImage` with no `v` synthesis. Shared with +// `functions-docker.ts`'s `resolveEdgeRuntimeVersion`, which selects it. +export const DENO1_EDGE_RUNTIME_VERSION = "v1.68.4"; /** * Go: `replaceImageTag(Images.EdgeRuntime, tag)` (`pkg/config/utils.go:81-84`) @@ -42,10 +49,25 @@ const DEFAULT_EDGE_RUNTIME_TAG = DEFAULT_EDGE_RUNTIME_IMAGE.split(":")[1] ?? ""; * default above and `resolveEdgeRuntimeVersion`'s deno-1 constant. * Single home for the repository too — only the tag half is parameterized, * so a `supabase/edge-runtime` rename in the Dockerfile propagates whole. + * + * `deno_version = 1` is a locked docker.io-only exception (no slim build): + * the "tag" it selects is really a whole different image squeezed through + * this tag-shaped API, so it bypasses the (possibly slim-rewritten) default + * base entirely and returns the full docker.io ref. Flag-off this is + * byte-identical to the general path, since the default base is already + * docker.io then. The tag check deliberately also catches an explicit + * `.temp/edge-runtime-version` pin of this exact tag under the slim flag: + * no slim build of it exists either, so docker.io is the only resolvable + * image for that tag regardless of WHY it was selected — a separate + * deno_version signal would change nothing observable. */ export function edgeRuntimeImage(tag: string): string { - const index = DEFAULT_EDGE_RUNTIME_IMAGE.indexOf(":"); - return DEFAULT_EDGE_RUNTIME_IMAGE.slice(0, index + 1) + tag.trim(); + if (tag === DENO1_EDGE_RUNTIME_VERSION) { + return `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`; + } + const base = defaultEdgeRuntimeImage(); + const index = base.indexOf(":"); + return base.slice(0, index + 1) + tag.trim(); } /** @@ -62,6 +84,6 @@ export const resolveEdgeRuntimeVersionPin = Effect.fnUntraced(function* (supabas ).pipe( Effect.map((version) => version.trim()), Effect.catch(() => Effect.succeed("")), - Effect.map((version) => version || DEFAULT_EDGE_RUNTIME_TAG), + Effect.map((version) => version || (defaultEdgeRuntimeImage().split(":")[1] ?? "")), ); }); diff --git a/apps/cli/src/shared/functions/functions.shared.unit.test.ts b/apps/cli/src/shared/functions/functions.shared.unit.test.ts new file mode 100644 index 0000000000..9b16ed2c03 --- /dev/null +++ b/apps/cli/src/shared/functions/functions.shared.unit.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { DENO1_EDGE_RUNTIME_VERSION, edgeRuntimeImage } from "./functions.shared.ts"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("edgeRuntimeImage", () => { + it("keeps the deno1 tag on the docker.io image even when the slim flag is on", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(edgeRuntimeImage(DENO1_EDGE_RUNTIME_VERSION)).toBe( + `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`, + ); + }); + + it("rewrites a non-deno1 tag onto the slim ghcr.io image when the flag is on", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(edgeRuntimeImage("v1.74.3")).toBe("ghcr.io/supabase/cli/edge-runtime:v1.74.3"); + }); + + it("keeps the deno1 tag on the docker.io image while the flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(edgeRuntimeImage(DENO1_EDGE_RUNTIME_VERSION)).toBe( + `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`, + ); + }); +}); diff --git a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts index 6945a2d8f7..d67188e633 100644 --- a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts +++ b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { LEGACY_START_KONG_YML_TEMPLATE } from "../../legacy/commands/start/templates/kong.yml.ts"; -import { LEGACY_EDGE_RUNTIME_IMAGE } from "../../legacy/shared/legacy-edge-runtime-image.ts"; +import { legacyEdgeRuntimeImage } from "../../legacy/shared/legacy-edge-runtime-image.ts"; import { ensureImage, resolveDeadline } from "../../../tests/helpers/docker-image.ts"; import { dockerfileServiceImage } from "../services/dockerfile-images.ts"; import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; @@ -145,7 +145,7 @@ describe("functions serve runtime template (offline)", () => { "boots under edge-runtime with networking disabled and fetches nothing remote", { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, async () => { - const runtimeImage = await ensureImage(LEGACY_EDGE_RUNTIME_IMAGE); + const runtimeImage = await ensureImage(legacyEdgeRuntimeImage()); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-offline-e2e-")); const container = `supabase-serve-offline-e2e-${process.pid.toString()}`; try { @@ -209,7 +209,7 @@ describe("functions serve runtime template (offline)", () => { "returns canonical JWT auth failures", { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, async () => { - const runtimeImage = await ensureImage(LEGACY_EDGE_RUNTIME_IMAGE); + const runtimeImage = await ensureImage(legacyEdgeRuntimeImage()); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-auth-e2e-")); const container = `supabase-serve-auth-e2e-${process.pid.toString()}`; try { @@ -293,7 +293,7 @@ describe("functions serve runtime template (offline)", () => { async () => { const imageDeadline = resolveDeadline(); const [runtimeImage, kongImage] = await Promise.all([ - ensureImage(LEGACY_EDGE_RUNTIME_IMAGE, imageDeadline), + ensureImage(legacyEdgeRuntimeImage(), imageDeadline), ensureImage(dockerfileServiceImage("kong"), imageDeadline), ]); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-kong-e2e-")); diff --git a/apps/cli/src/shared/functions/serve.errors.ts b/apps/cli/src/shared/functions/serve.errors.ts new file mode 100644 index 0000000000..9f16bbed29 --- /dev/null +++ b/apps/cli/src/shared/functions/serve.errors.ts @@ -0,0 +1,24 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +/** + * A newline-containing function secret while `SUPABASE_USE_SLIM_IMAGES` is on. + * Multiline values reach the container through a sourced shell script, and the + * slim edge-runtime image is distroless — it ships no shell to source it. + */ +export class SlimEdgeRuntimeMultilineSecretError extends Data.TaggedError( + "SlimEdgeRuntimeMultilineSecretError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The remediation is editing the env file or the flag, not re-running with + // different arguments, so `invalidConfig` (which declares its + // update-config suggestion) fits better than `invalidInput`. + return actionability.invalidConfig; + } +} diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index a4196ec7f7..2b3b79a24a 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -75,7 +75,9 @@ import { toDockerPath, } from "./functions-docker.ts"; import { loadFunctionsCliConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; +import { SlimEdgeRuntimeMultilineSecretError } from "./serve.errors.ts"; import { edgeRuntimeImage, resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; +import { usesSlimImageRuntime } from "../services/slim-images.ts"; const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); const defaultCliConfig = decodeCliConfig({}); @@ -108,7 +110,8 @@ const ignoredDirNames = new Set([ const dockerLogRetryDelay = Duration.millis(400); const dockerLogDiagnosticTailLength = 4_096; const defaultSupabaseEnv = "development"; -const serveMainContainerPath = "/root/index.ts"; +const slimServeMainDir = "/tmp"; +const dockerIoServeMainDir = "/root"; const shellVariableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/; let cachedLegacyFunctionsServeMainTemplate: string | undefined; const watchIgnoreGlobs = [ @@ -1758,21 +1761,32 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo }); const labels = dockerProjectLabels(projectId); + const slimEdgeRuntime = usesSlimImageRuntime(input.image); + const serveMainDir = slimEdgeRuntime ? slimServeMainDir : dockerIoServeMainDir; + const serveMainFile = `${serveMainDir}/index.ts`; const runtimeCommand = [ "edge-runtime", "start", - "--main-service=/root", + `--main-service=${serveMainDir}`, `--port=${dockerRuntimeServerPort}`, `--policy=${input.config.edgeRuntimePolicy}`, ...buildFunctionsServeInspectArgs(input.inspectMode, input.inspectMain), ...(input.debug ? ["--verbose"] : []), ]; + if (slimEdgeRuntime && dockerMultilineEnvScript !== undefined) { + return yield* Effect.fail( + new SlimEdgeRuntimeMultilineSecretError({ + message: + "SUPABASE_USE_SLIM_IMAGES cannot source multiline function secrets: the slim edge-runtime image has no shell. Unset the flag, or remove newline-containing values from the functions env file.", + }), + ); + } const serveMainTemplate = yield* Effect.promise(() => getLegacyFunctionsServeMainTemplate()); // Streamed in via `docker cp` between create and start: embedding the template in the // `sh -c` argv hits Windows ENAMETOOLONG (#5711), and a single-file host bind mounts as // an empty directory on daemons that cannot see this host's filesystem (#6254, #4190). const serveMainArchive = yield* Effect.tryPromise({ - try: () => containerArchiveBytes({ [serveMainContainerPath]: serveMainTemplate }), + try: () => containerArchiveBytes({ [serveMainFile]: serveMainTemplate }), catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), }); const containerProjectRoot = toDockerPath(input.projectRoot); @@ -1804,11 +1818,14 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo ...(input.inspectMode === undefined ? [] : ["-p", `${input.config.edgeRuntimeInspectorPort}:${dockerRuntimeInspectorPort}`]), - "--entrypoint", - "sh", + ...(slimEdgeRuntime ? [] : ["--entrypoint", "sh"]), input.image, - "-c", - buildServeEntrypointCommand(runtimeCommand, dockerMultilineEnvScript?.scriptPath), + ...(slimEdgeRuntime + ? runtimeCommand.slice(1) + : [ + "-c", + buildServeEntrypointCommand(runtimeCommand, dockerMultilineEnvScript?.scriptPath), + ]), ]; // The container must exist for `docker cp` to have a target, and must not be running diff --git a/apps/cli/src/shared/services/dockerfile-images.ts b/apps/cli/src/shared/services/dockerfile-images.ts index d9982ddf9f..2b9bbd032c 100644 --- a/apps/cli/src/shared/services/dockerfile-images.ts +++ b/apps/cli/src/shared/services/dockerfile-images.ts @@ -1,4 +1,5 @@ import serviceImagesDockerfile from "../../../../cli-go/pkg/config/templates/Dockerfile" with { type: "text" }; +import { slimImageForAlias } from "./slim-images.ts"; export interface DockerfileImageSpec { readonly alias: string; @@ -30,7 +31,8 @@ export function parseDockerfileServiceImages( export const dockerfileServiceImages = parseDockerfileServiceImages(serviceImagesDockerfile); -export function dockerfileServiceImage(alias: string): string { +/** The docker.io reference exactly as pinned in the Dockerfile manifest. */ +export function dockerfileServiceImageRaw(alias: string): string { const service = dockerfileServiceImages.find((image) => image.alias === alias); if (service === undefined) { throw new Error(`Missing service image alias '${alias}' in Dockerfile manifest.`); @@ -38,3 +40,13 @@ export function dockerfileServiceImage(alias: string): string { return service.image; } + +/** + * The default image for `alias`, rewritten to its slim `ghcr.io/supabase/cli` + * equivalent when `SUPABASE_USE_SLIM_IMAGES` is set. This is the single choke + * point for default service images; use `dockerfileServiceImageRaw` where the + * docker.io identity itself is the contract (user-facing short names). + */ +export function dockerfileServiceImage(alias: string): string { + return slimImageForAlias(alias, dockerfileServiceImageRaw(alias)); +} diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 56e5aa666d..0b0e42be63 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -1,5 +1,6 @@ import { styleText } from "node:util"; import { makeApiClient, type ApiClient } from "@supabase/api/effect"; +import { dockerImageForService } from "@supabase/stack/versions"; import { Data, Duration, Effect, Exit, Redacted } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -14,6 +15,7 @@ import { parseDockerfileServiceImages, type DockerfileImageSpec, } from "./dockerfile-images.ts"; +import { slimImageForAlias, slimImagesEnabled } from "./slim-images.ts"; export { parseDockerfileServiceImages } from "./dockerfile-images.ts"; @@ -47,6 +49,7 @@ export interface LocalServiceImageOptions { const PROJECT_REF_PATTERN = /^[a-z]{20}$/; interface ServiceImageSpec { + readonly alias: string; readonly image: string; readonly remoteService: RemoteServiceName | undefined; readonly localService: LocalServiceVersionName; @@ -91,6 +94,7 @@ function localServiceImagesFromSpecs( } return { + alias: service.alias, image, remoteService: service.remoteService, localService: service.localService, @@ -141,12 +145,22 @@ function localServiceImagesForOptions( options: LocalServiceImageOptions = {}, ): ReadonlyArray { const normalizeVersionTags = options.normalizeVersionTags ?? true; + const slim = slimImagesEnabled(); return LOCAL_SERVICE_IMAGES.map((service) => { - const baseImage = options.imageOverrides?.[service.localService] ?? service.image; + // An explicit `imageOverrides` entry is a caller-chosen ref (the Postgres + // major-version fallback, a configured edge-runtime image) with no slim + // counterpart, so it keeps the docker.io path even with the flag on. + const override = options.imageOverrides?.[service.localService]; + const baseImage = override ?? slimImageForAlias(service.alias, service.image); const version = options.serviceVersions?.[service.localService]; if (version === undefined || version.trim().length === 0) { return baseImage === service.image ? service : { ...service, image: baseImage }; } + if (override === undefined && slim) { + // The catalog owns the slim tag scheme, which differs from docker.io's for + // `pooler`/`analytics`; a verbatim tag swap would miss the `v` prefix. + return { ...service, image: dockerImageForService(service.localService, version) }; + } return { ...service, image: replaceImageTag( diff --git a/apps/cli/src/shared/services/services.shared.unit.test.ts b/apps/cli/src/shared/services/services.shared.unit.test.ts index bb343ee103..480647eb78 100644 --- a/apps/cli/src/shared/services/services.shared.unit.test.ts +++ b/apps/cli/src/shared/services/services.shared.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { Effect, Redacted } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import serviceImagesDockerfile from "../../../../cli-go/pkg/config/templates/Dockerfile" with { type: "text" }; @@ -20,6 +20,10 @@ const runLinkedFetch = (input: Parameters[0]) Effect.runPromise(fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer))); describe("services shared", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + test("parses service images from Dockerfile FROM aliases", () => { expect( parseDockerfileServiceImages(` @@ -68,6 +72,55 @@ describe("services shared", () => { ]); }); + test("lists slim images when SUPABASE_USE_SLIM_IMAGES is set", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(listLocalServiceVersions().map((row) => row.name)).toEqual([ + "ghcr.io/supabase/cli/postgres", + "ghcr.io/supabase/cli/auth", + "ghcr.io/supabase/cli/postgrest", + "ghcr.io/supabase/cli/realtime", + "ghcr.io/supabase/cli/storage", + "ghcr.io/supabase/cli/edge-runtime", + "ghcr.io/supabase/cli/studio", + "ghcr.io/supabase/cli/pgmeta", + "ghcr.io/supabase/cli/analytics", + "ghcr.io/supabase/cli/pooler", + ]); + }); + + test("applies pins on the slim tag scheme when SUPABASE_USE_SLIM_IMAGES is set", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect( + listLocalServiceVersions({ serviceVersions: { pooler: "2.0.0", analytics: "1.4.0" } }), + ).toEqual( + expect.arrayContaining([ + { name: "ghcr.io/supabase/cli/pooler", local: "v2.0.0", remote: "" }, + { name: "ghcr.io/supabase/cli/analytics", local: "v1.4.0", remote: "" }, + ]), + ); + }); + + // The Postgres major-version fallback and a configured edge-runtime image are + // caller-chosen refs with no slim counterpart, so the flag must not touch them. + test("leaves explicit image overrides on docker.io when SUPABASE_USE_SLIM_IMAGES is set", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const rows = listLocalServiceVersions({ + imageOverrides: { + postgres: "supabase/postgres:15.8.1.085", + "edge-runtime": "supabase/edge-runtime:v1.68.4", + }, + normalizeVersionTags: false, + serviceVersions: { postgres: "15.8.1.090" }, + }); + + expect(rows).toEqual( + expect.arrayContaining([ + { name: "supabase/postgres", local: "15.8.1.090", remote: "" }, + { name: "supabase/edge-runtime", local: "v1.68.4", remote: "" }, + ]), + ); + }); + test("can preserve raw local service version overrides", () => { expect( listLocalServiceVersions({ diff --git a/apps/cli/src/shared/services/slim-images.ts b/apps/cli/src/shared/services/slim-images.ts new file mode 100644 index 0000000000..fca55caed3 --- /dev/null +++ b/apps/cli/src/shared/services/slim-images.ts @@ -0,0 +1,81 @@ +import { dockerImageForService, type ServiceName } from "@supabase/stack/versions"; + +const SLIM_IMAGES_ENV = "SUPABASE_USE_SLIM_IMAGES"; +const SLIM_IMAGE_PREFIX = "ghcr.io/supabase/cli/"; + +/** + * Maps embedded-Dockerfile aliases onto the slim service catalog. Aliases with + * no slim build (kong, the `differ`/`migra`/`pgprove` job images) are absent and + * keep their docker.io reference. + */ +const SLIM_SERVICE_BY_ALIAS: Readonly> = { + pg: "postgres", + gotrue: "auth", + postgrest: "postgrest", + realtime: "realtime", + storage: "storage", + edgeruntime: "edge-runtime", + studio: "studio", + pgmeta: "pgmeta", + logflare: "analytics", + supavisor: "pooler", + vector: "vector", + imgproxy: "imgproxy", + mailpit: "mailpit", +}; + +/** + * Ambient process env only — the project-dotenv installers + * (`legacy-db-config.toml-read.ts`, `legacy-local-project-context.ts`) copy + * only a fixed set of keys into `process.env`, not arbitrary flags, so a + * value set only in `supabase/.env` is not observed here. Read per call + * rather than cached so tests can stub the ambient env per case. + */ +export function slimImagesEnabled(): boolean { + const value = process.env[SLIM_IMAGES_ENV]; + return value === "true" || value === "1"; +} + +/** + * Rewrites a docker.io image reference to its `ghcr.io/supabase/cli` slim + * equivalent, keeping the Dockerfile's pinned version. The catalog owns tag + * normalization (`v`-prefixing, `tagPrefix`), so pins that differ only in + * prefix between the two registries (`supavisor`, `logflare`) land on the right + * slim tag. Vector's docker.io tags carry an `-alpine` variant suffix that the + * slim build does not publish, so the strip is scoped to `vector` only — an + * `-alpine`-suffixed pin on any other service is a real tag, not a variant marker. + */ +export function toSlimImage(alias: string, image: string): string { + const service = SLIM_SERVICE_BY_ALIAS[alias]; + if (service === undefined) { + return image; + } + + const tagSeparator = image.lastIndexOf(":"); + if (tagSeparator === -1) { + return image; + } + + const rawTag = image.slice(tagSeparator + 1); + const tag = alias === "vector" ? rawTag.replace(/-alpine$/, "") : rawTag; + return dockerImageForService(service, tag); +} + +/** `toSlimImage` behind the feature flag; a no-op while the flag is off. */ +export function slimImageForAlias(alias: string, image: string): string { + return slimImagesEnabled() ? toSlimImage(alias, image) : image; +} + +/** Slim images are published only under this prefix; single home for the check. */ +export function isSlimImageRef(image: string): boolean { + return image.startsWith(SLIM_IMAGE_PREFIX); +} + +/** + * True when the flag is on AND `image` is a slim ghcr ref. Spec builders and + * one-shot jobs use this so a ghcr-shaped override with the flag off stays on + * the docker.io contract (same gate as {@link legacyIsSlimPostgresImage}). + */ +export function usesSlimImageRuntime(image: string): boolean { + return slimImagesEnabled() && isSlimImageRef(image); +} diff --git a/apps/cli/src/shared/services/slim-images.unit.test.ts b/apps/cli/src/shared/services/slim-images.unit.test.ts new file mode 100644 index 0000000000..f898b41b28 --- /dev/null +++ b/apps/cli/src/shared/services/slim-images.unit.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { dockerfileServiceImages } from "./dockerfile-images.ts"; +import { + slimImageForAlias, + slimImagesEnabled, + toSlimImage, + usesSlimImageRuntime, +} from "./slim-images.ts"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +const imageForAlias = (alias: string): string => { + const spec = dockerfileServiceImages.find((image) => image.alias === alias); + if (spec === undefined) { + throw new Error(`Missing service image alias '${alias}' in Dockerfile manifest.`); + } + return spec.image; +}; + +describe("toSlimImage", () => { + it.each([ + ["pg", "ghcr.io/supabase/cli/postgres"], + ["gotrue", "ghcr.io/supabase/cli/auth"], + ["postgrest", "ghcr.io/supabase/cli/postgrest"], + ["realtime", "ghcr.io/supabase/cli/realtime"], + ["storage", "ghcr.io/supabase/cli/storage"], + ["edgeruntime", "ghcr.io/supabase/cli/edge-runtime"], + ["studio", "ghcr.io/supabase/cli/studio"], + ["pgmeta", "ghcr.io/supabase/cli/pgmeta"], + ["logflare", "ghcr.io/supabase/cli/analytics"], + ["supavisor", "ghcr.io/supabase/cli/pooler"], + ["vector", "ghcr.io/supabase/cli/vector"], + ["imgproxy", "ghcr.io/supabase/cli/imgproxy"], + ["mailpit", "ghcr.io/supabase/cli/mailpit"], + ])("maps the %s manifest pin onto %s", (alias, repository) => { + const translated = toSlimImage(alias, imageForAlias(alias)); + expect(translated.slice(0, translated.lastIndexOf(":"))).toBe(repository); + }); + + it("keeps the Dockerfile version pin instead of the catalog default", () => { + expect(toSlimImage("pg", "supabase/postgres:17.6.1.165")).toBe( + "ghcr.io/supabase/cli/postgres:17.6.1.165", + ); + expect(toSlimImage("studio", "supabase/studio:2026.08.17-sha-0c1da8f")).toBe( + "ghcr.io/supabase/cli/studio:2026.08.17-sha-0c1da8f", + ); + }); + + it("v-prefixes pins whose slim tag scheme differs from docker.io's", () => { + expect(toSlimImage("supavisor", "supabase/supavisor:2.9.10")).toBe( + "ghcr.io/supabase/cli/pooler:v2.9.10", + ); + expect(toSlimImage("logflare", "supabase/logflare:1.50.4")).toBe( + "ghcr.io/supabase/cli/analytics:v1.50.4", + ); + expect(toSlimImage("pgmeta", "supabase/postgres-meta:v0.98.0")).toBe( + "ghcr.io/supabase/cli/pgmeta:v0.98.0", + ); + }); + + it("strips vector's docker.io -alpine variant suffix", () => { + expect(toSlimImage("vector", "timberio/vector:0.53.0-alpine")).toBe( + "ghcr.io/supabase/cli/vector:0.53.0", + ); + }); + + it("does not strip -alpine from a non-vector service's tag", () => { + expect(toSlimImage("studio", "supabase/studio:2026.08.17-alpine")).toBe( + "ghcr.io/supabase/cli/studio:2026.08.17-alpine", + ); + }); + + it("passes through aliases with no slim build", () => { + for (const alias of ["kong", "differ", "migra", "pgprove"]) { + const image = imageForAlias(alias); + expect(toSlimImage(alias, image)).toBe(image); + } + }); + + it("passes through an untagged reference", () => { + expect(toSlimImage("pg", "supabase/postgres")).toBe("supabase/postgres"); + }); +}); + +describe("slimImagesEnabled", () => { + it.each([ + ["true", true], + ["1", true], + ["false", false], + ["0", false], + ["yes", false], + ["TRUE", false], + ["", false], + ])("reads %j as %s", (value, expected) => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", value); + expect(slimImagesEnabled()).toBe(expected); + }); + + it("is off when unset", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(slimImagesEnabled()).toBe(false); + }); +}); + +describe("slimImageForAlias", () => { + it("is a no-op while the flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(slimImageForAlias("pg", "supabase/postgres:17.6.1.165")).toBe( + "supabase/postgres:17.6.1.165", + ); + }); + + it("translates when the flag is on", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(slimImageForAlias("pg", "supabase/postgres:17.6.1.165")).toBe( + "ghcr.io/supabase/cli/postgres:17.6.1.165", + ); + }); +}); + +describe("usesSlimImageRuntime", () => { + it("is false while the flag is off even for a ghcr ref", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(usesSlimImageRuntime("ghcr.io/supabase/cli/postgres:17.6.1.165")).toBe(false); + }); + + it("is true only when the flag is on and the ref is slim", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + expect(usesSlimImageRuntime("ghcr.io/supabase/cli/auth:v2.196.0")).toBe(true); + expect(usesSlimImageRuntime("supabase/gotrue:v2.196.0")).toBe(false); + }); +}); diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 36b84cee04..96d472377b 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -916,6 +916,10 @@ const LEGACY_SHADOW_STARTING_STATE = * exclusive with `dbInspectFailsWith`, which instead reports a daemon-unreachable failure * (`legacyIsDockerDaemonUnreachable`) with the given stderr text — enforced below (a test * that sets both throws immediately, rather than one option silently winning). + * + * `dbInspectImage` makes the same `supabase_db_`-prefixed inspect report a `Config.Image` + * value instead — for `ensureLocalPostgresImageCurrent`'s stale-image guard, which reads + * that field from the same call `legacyIsLocalDbRunning` only checks the exit code of. */ export function mockLegacyShadowContainerCliSpawner( opts: { @@ -924,6 +928,7 @@ export function mockLegacyShadowContainerCliSpawner( readonly failRemove?: boolean; readonly dbNotRunning?: boolean; readonly dbInspectFailsWith?: string; + readonly dbInspectImage?: string; } = {}, ): { readonly layer: Layer.Layer; @@ -982,6 +987,22 @@ export function mockLegacyShadowContainerCliSpawner( getOutputFd: () => Stream.empty, }); } + if (isLocalDbInspect && opts.dbInspectImage !== undefined) { + const inspectJson = JSON.stringify([{ Config: { Image: opts.dbInspectImage } }]); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(7000 + spawned.length), + stdout: Stream.fromIterable([encoder.encode(inspectJson)]), + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + } let stdoutLines: ReadonlyArray = []; let stderrLines: ReadonlyArray = []; let exitCode = 0; diff --git a/packages/stack/package.json b/packages/stack/package.json index 68db806a45..3fe4966c4e 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -17,6 +17,7 @@ "default": "./src/managed-node.ts" }, "./managed-model": "./src/managed/model.ts", + "./versions": "./src/versions.ts", "./testing": "./src/testing.ts", "./daemon-bun": "./src/daemon-bun.ts" },