diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index fa96d9b4d4..032515d6f7 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -1,5 +1,5 @@ # Exposed for updates by .github/dependabot.yml -FROM supabase/postgres:17.6.1.165 AS pg +FROM supabase/postgres:17.6.1.166 AS pg # Append to ServiceImages when adding new dependencies below FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.30.2 AS mailpit @@ -9,11 +9,11 @@ FROM supabase/studio:2026.08.24-sha-8ec45b2 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.9 AS realtime -FROM supabase/storage-api:v1.71.0 AS storage -FROM supabase/logflare:1.50.6 AS logflare +FROM supabase/realtime:v2.129.3 AS realtime +FROM supabase/storage-api:v1.72.1 AS storage +FROM supabase/logflare:1.50.4 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ FROM supabase/migra:3.0.1663481299 AS migra 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 1cb7f5c644..4e00ac4cfe 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,9 @@ 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` rewrites the current Dockerfile pin to + `ghcr.io/supabase/cli/postgres`; a historical `.temp/postgres-version` pin stays on + docker.io. The restore entrypoint is the same on both families. 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 @@ -38,7 +41,8 @@ composition reuses too — see that command's `SIDE_EFFECTS.md`): 7. On a fresh volume with `--from-backup` unset: run the `SetupLocalDatabase`-equivalent pipeline (`legacy/shared/db-bootstrap/db-setup.ts`) — initial schema (PG<=14: SQL over a direct `LegacyDbConnection`; PG>=15: up to three one-shot `docker run --rm` migrate jobs - for realtime/storage/auth, each gated on its own `enabled` flag), API-privilege + for realtime/storage/auth, each gated on its own `enabled` flag; slim Realtime still + runs its one-shot so user migrations see the tenant), API-privilege revocation, `[db.vault]` secret upsert, `supabase/roles.sql` seed, and finally either every pending migration + seed, OR — when `--experimental`/`SUPABASE_EXPERIMENTAL` is set AND `[experimental.pgdelta] enabled` is false — every `db.migrations.schema_paths` file @@ -106,26 +110,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 the current Dockerfile pin from the slim `ghcr.io/supabase/cli/` builds (`true`/`1` enable); historical `.temp` pins, the pg 13/14/15 fallbacks, OrioleDB images, and the pg-delta catalog-warmup container 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 fafda720a6..99841ac409 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 @@ -389,6 +389,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", () => { @@ -682,6 +683,25 @@ describe("legacy db start", () => { }, ); + it.live( + "--from-backup under SUPABASE_USE_SLIM_IMAGES uses the same restore entrypoint as docker.io", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); + return Effect.gen(function* () { + yield* legacyDbStart(flags("/abs/host/backup.sql")).pipe(Effect.provide(layer)); + const args = createArgs(child.spawned); + expect(args).not.toBeUndefined(); + const script = args?.[(args?.indexOf("-c") ?? -1) + 1]; + expect(script).toContain("/docker-entrypoint-initdb.d/migrate.sh"); + expect(bindsFromCreateArgs(args ?? [])).toContain( + "/abs/host/backup.sql:/etc/backup.sql:ro", + ); + expect(dbSetupJobCalls(child.spawned)).toHaveLength(0); + }).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }, + ); + it.live("resolves a relative --from-backup against the caller cwd, not the workdir", () => { const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()), 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..8065f527a7 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md @@ -32,7 +32,8 @@ | `docker run --rm ... --label com.supabase.cli.project= --label com.docker.compose.project= ...` | when Docker bundling is selected/available; labeled so orphaned containers can be associated with the project | Docker bundling may pull or run the configured edge-runtime image and uses the -`supabase_edge_runtime_` Deno cache volume. +`supabase_edge_runtime_` Deno cache volume (mounted at +`/root/.cache/deno`). ## API Routes @@ -53,6 +54,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..d10123109f 100644 --- a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md @@ -35,14 +35,14 @@ ## Subprocesses -| Command | When | Purpose | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `docker info` | `--use-docker` (default), unless `--use-api` | check whether Docker is running before choosing the Docker-unbundle downloader | -| `docker image inspect ` (ECR, then GHCR, then Docker Hub) | Docker-unbundle path, when Docker is running | check whether the edge-runtime image is already cached locally, tried in registry order, before the network/volume ensure | -| `docker pull ` | Docker-unbundle path, cache miss on a candidate | pull with 2 retries (4s/8s backoff) before falling through to the next registry candidate | -| `docker network inspect` / `network create` / `volume create` | Docker-unbundle path, when Docker is running | ensure the shared per-project network/named volume exist (same primitives as `functions deploy`'s Docker bundler) | -| `docker run --rm ... --label com.supabase.cli.project= --label com.docker.compose.project= unbundle --eszip ... --output ...` | Docker-unbundle path, when Docker is running | extract the downloaded eszip into `supabase/functions//...`; labeled so orphaned containers can be associated with the project | -| `supabase-go functions download ... --legacy-bundle` | `--legacy-bundle` only | preserve the hidden, deprecated pre-1.120.0 bundling fallback (native TS port tracked separately, CLI-1963) | +| Command | When | Purpose | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docker info` | `--use-docker` (default), unless `--use-api` | check whether Docker is running before choosing the Docker-unbundle downloader | +| `docker image inspect ` (ECR, then GHCR, then Docker Hub) | Docker-unbundle path, when Docker is running | check whether the edge-runtime image is already cached locally, tried in registry order, before the network/volume ensure | +| `docker pull ` | Docker-unbundle path, cache miss on a candidate | pull with 2 retries (4s/8s backoff) before falling through to the next registry candidate | +| `docker network inspect` / `network create` / `volume create` | Docker-unbundle path, when Docker is running | ensure the shared per-project network/named volume exist (same primitives as `functions deploy`'s Docker bundler); the Deno-cache volume is `supabase_edge_runtime_` (mounted at `/root/.cache/deno`) | +| `docker run --rm ... --label com.supabase.cli.project= --label com.docker.compose.project= unbundle --eszip ... --output ...` | Docker-unbundle path | extract the downloaded eszip into `supabase/functions//...`; labeled so orphaned containers can be associated with the project | +| `supabase-go functions download ... --legacy-bundle` | `--legacy-bundle` only | preserve the hidden, deprecated pre-1.120.0 bundling fallback (native TS port tracked separately, CLI-1963) | The `--legacy-bundle` delegated call runs with `SUPABASE_TELEMETRY_DISABLED=1` so the Go child's own `cli_command_executed` doesn't double-count on top of @@ -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..69a2655328 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 | @@ -106,7 +107,7 @@ Long-running raw log / error events only; there is no terminal `result` event on - Each restart re-reads config, rebuilds per-function bind mounts, recreates the `supabase_edge_runtime_` container, and best-effort reloads Kong afterwards. - The command creates or reuses Docker resources derived from the resolved project id: - container: `supabase_edge_runtime_` - - named volume: `supabase_edge_runtime_` + - named volume: `supabase_edge_runtime_` (mounted at `/root/.cache/deno`) - network: `supabase_network_` unless `--network-id` overrides it - Inspector mode exposes the configured `edge_runtime.inspector_port` on the host and sets `SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0`. - Config `env()` interpolation uses a project environment resolved by the command itself (ambient `process.env` layered under `.env..local` / `.env.local` / `.env.` / `.env`) and passed into `loadCliConfig`. The command does not move/hide any project files. One `process.env` mutation exists: the shared config pipeline (`legacyLoadLocalProjectContext`, shared with `deploy`/`download`/`start`) installs a project-dotenv-only `BITBUCKET_CLONE_DIR` into `process.env`. 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..4ffb19371b 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -47,10 +47,10 @@ config for that ref to build the fallback connection (the saved workdir ## Subprocesses -| Command | When | Purpose | -| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------- | -| `docker`/`podman container inspect supabase_db_` | `--local` | assert `supabase start` is running | -| `docker`/`podman run --rm --network --env … node dist/server/server.js` | `--local`, `--db-url`, project-ref paths with non-TypeScript `--lang` | run pg-meta to generate types from a live database | +| Command | When | Purpose | +| ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docker`/`podman container inspect supabase_db_` | `--local` | assert `supabase start` is running | +| `docker`/`podman run --rm --network --env … [node dist/server/server.js]` | `--local`, `--db-url`, project-ref paths with non-TypeScript `--lang` | run pg-meta to generate types from a live database. Slim pg-meta (`SUPABASE_USE_SLIM_IMAGES` and a current Dockerfile pin) already has `ENTRYPOINT /node/bin/node` plus the image `CMD`, so the extra `node dist/server/server.js` argv is omitted. A historical `.temp/pgmeta-version` pin stays on docker.io and still gets those two args. | A raw TCP `SSLRequest` probe is also opened to the target database host/port to detect TLS support before launching pg-meta, with the default 10s pg-delta probe @@ -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 current Dockerfile pg-meta pin from the slim `ghcr.io/supabase/cli/pgmeta` build (`true`/`1` enable); a historical `.temp/pgmeta-version` pin stays on docker.io | 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/gen/types/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index 63a47aa0fd..39396baa52 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -45,6 +45,7 @@ import type { LegacyGenTypesFlags } from "./types.command.ts"; import { LegacyGenTypesNetworkError, LegacyGenTypesUnexpectedStatusError } from "./types.errors.ts"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; +import { usesSlimImageRuntime } from "../../../../shared/services/slim-images.ts"; import { defaultSchemas, buildPostgresUrl, @@ -440,15 +441,17 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le // `--network-id` overrides any base network mode (even the // "host" mode used for --db-url), so honour the override here too. const networkMode = Option.isSome(networkId) ? networkId.value : input.networkMode; + const pgmetaImage = resolvePgmetaImage(input.pgmetaVersionOverride); const args = [ "run", "--rm", "--network", networkMode, ...env.flatMap((entry) => ["--env", entry]), - resolvePgmetaImage(input.pgmetaVersionOverride), - "node", - "dist/server/server.js", + pgmetaImage, + // Slim pg-meta is already `ENTRYPOINT /node/bin/node`; repeating + // `node` here becomes `node node dist/server/server.js`. + ...(usesSlimImageRuntime(pgmetaImage) ? [] : ["node", "dist/server/server.js"]), ]; const child = yield* spawnContainerCli(spawner, args, { stdin: "ignore", diff --git a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts index ba5355ed6b..da4d834594 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts @@ -3,6 +3,7 @@ import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; +import { vi } from "vitest"; import { BunServices } from "@effect/platform-bun"; import type { V1CreateLoginRoleOutput, @@ -2371,6 +2372,7 @@ describe("legacy gen types", () => { true, ); expect(child.spawned[1]?.args).toContain(resolvePgmetaImage()); + expect(child.spawned[1]?.args.slice(-2)).toEqual(["node", "dist/server/server.js"]); // The local/db-url paths have no project ref, so they must not // populate the linked-project cache. expect(linkedProjectCache.cached).toBe(false); @@ -2379,6 +2381,50 @@ describe("legacy gen types", () => { }), ); + it.live("omits the extra node argv on slim pg-meta", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-slim-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + `port = ${port}`, + ].join("\n"), + ); + + const { layer, child } = setup({ + workdir, + childStdout: ["export type Database = {};"], + }); + + await Effect.runPromise( + legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), + ); + + const runArgs = child.spawned[1]?.args ?? []; + const image = resolvePgmetaImage(); + expect(image.startsWith("ghcr.io/supabase/cli/pgmeta:")).toBe(true); + expect(runArgs).toContain(image); + expect(runArgs.slice(runArgs.indexOf(image) + 1)).toEqual([]); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }).pipe( + Effect.ensuring( + Effect.sync(() => { + vi.unstubAllEnvs(); + }), + ), + ), + ); + it.live("falls back to podman when the docker executable is missing for local generation", () => Effect.tryPromise({ try: () => diff --git a/apps/cli/src/legacy/commands/gen/types/types.shared.ts b/apps/cli/src/legacy/commands/gen/types/types.shared.ts index e8d8ef7ad3..c94d53665f 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.shared.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.shared.ts @@ -1,5 +1,6 @@ import { Effect } from "effect"; -import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; +import { dockerfileServiceImageRaw } from "../../../../shared/services/dockerfile-images.ts"; +import { slimImageForCurrentPin } from "../../../../shared/services/slim-images.ts"; import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; import { LegacyInvalidGenTypesDatabaseUrlError, @@ -140,23 +141,12 @@ export function buildPostgresUrl(input: { } export function resolvePgmetaImage(versionOverride?: string) { - const defaultImage = dockerfileServiceImage("pgmeta"); - if (versionOverride === undefined || versionOverride.trim().length === 0) { - return legacyGetRegistryImageUrl(defaultImage); - } - return legacyGetRegistryImageUrl( - replaceImageTag(defaultImage, `v${versionOverride.trim().replace(/^v/i, "")}`), - ); + const raw = dockerfileServiceImageRaw("pgmeta"); + const trimmed = versionOverride?.trim() ?? ""; + const pin = trimmed.length > 0 ? `v${trimmed.replace(/^v/i, "")}` : undefined; + return legacyGetRegistryImageUrl(slimImageForCurrentPin("pgmeta", raw, pin)); } export function legacyRootCaBundle() { return `${caStaging2021}${caProd2021}${caProd2025}`; } - -function replaceImageTag(image: string, tag: string): string { - const tagSeparator = image.lastIndexOf(":"); - if (tagSeparator === -1) { - return image; - } - return `${image.slice(0, tagSeparator + 1)}${tag}`; -} diff --git a/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts b/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts index b0c6c9b797..94a9dd7909 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit } from "effect"; +import { dockerfileServiceImageRaw } from "../../../../shared/services/dockerfile-images.ts"; +import { toSlimImage } from "../../../../shared/services/slim-images.ts"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; import { legacyParseSchemaFlags } from "../../../shared/legacy-schema-flags.ts"; import { @@ -14,6 +16,9 @@ import { resolvePgmetaImage, } from "./types.shared.ts"; +const currentPgmeta = dockerfileServiceImageRaw("pgmeta"); +const currentPgmetaTag = currentPgmeta.split(":")[1] ?? ""; + function withEnv(key: string, value: string | undefined, run: () => T): T { const previous = process.env[key]; if (value === undefined) { @@ -180,6 +185,22 @@ describe("resolvePgmetaImage", () => { ); expect(image).toBe("my.registry.example/supabase/postgres-meta:v1.2.3"); }); + + it("slim-translates the current pin and skips registry rewrite", () => { + const image = withEnv("SUPABASE_USE_SLIM_IMAGES", "1", () => + withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, () => + resolvePgmetaImage(currentPgmetaTag), + ), + ); + expect(image).toBe(toSlimImage("pgmeta", currentPgmeta)); + }); + + it("keeps a historical pg-meta pin on docker.io under the slim flag", () => { + const image = withEnv("SUPABASE_USE_SLIM_IMAGES", "1", () => + withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "docker.io", () => resolvePgmetaImage("1.2.3")), + ); + expect(image).toBe("supabase/postgres-meta:v1.2.3"); + }); }); describe("schema and id helpers", () => { 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/services/services.handler.ts b/apps/cli/src/legacy/commands/services/services.handler.ts index 5dc266c91b..71e940f350 100644 --- a/apps/cli/src/legacy/commands/services/services.handler.ts +++ b/apps/cli/src/legacy/commands/services/services.handler.ts @@ -171,6 +171,7 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le imageOverrides, normalizeVersionTags: false, serviceVersions, + slimCurrentPinOnly: true, }; let rows = listLocalServiceVersions(localImageOptions); diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 57ab6738e6..aeb2ab2f61 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -32,7 +32,10 @@ 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 Realtime still runs the one-shot +`eval` health_check so user migrations see the tenant before long-running containers boot; +slim Storage's one-shot uses the docker.io storage image because slim has no +`migrate-call.js`; slim Auth runs 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 + @@ -73,23 +76,23 @@ command. ## Files Read -| Path | Format | When | -| ----------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always | -| `/supabase/.env`, `.env.local` | dotenv | always (`.env.local` skipped when `SUPABASE_ENV=test`) | -| project-root / `SUPABASE_ENV`-selected dotenv file | dotenv | always, same precedence chain as `stop`/`status` | -| `auth.signing_keys_path` file | JSON | when configured | -| `api.tls.cert_path` / `api.tls.key_path` | PEM | when `api.tls.enabled` | -| `auth.email.template.*` / `auth.email.notification.*` content files | text | when configured | -| GCP JWT credentials file | JSON | when `analytics.backend = "bigquery"` | -| `/supabase/roles.sql` | SQL | on a fresh volume (custom-roles seed) — the "Seeding globals..." message always prints first; the file itself is only read if it exists, tolerating a missing file | -| `/supabase/migrations/*.sql`, `supabase/seed.sql` | SQL | on a fresh volume, via the standard migration-apply + seed pipeline | -| `/supabase/` (files/directories/globs) | SQL | on a fresh volume, INSTEAD of `migrations/*.sql`, when `--experimental`/`SUPABASE_EXPERIMENTAL` is set and `[experimental.pgdelta] enabled` is false | -| `/supabase/.branches/_current_branch` | text | on every start, existence check before writing (see "Files Written") | -| `/supabase/functions/**` | — | when Edge Runtime starts, and independently when Studio starts (function discovery/config resolution + Docker bind mounts, regardless of whether Edge Runtime itself is enabled) | -| `/supabase/.temp/storage-migration` | text | always — linked-project Storage migration pin (`DB_MIGRATIONS_FREEZE_AT`), written by `supabase link`; absent/unreadable resolves to no pin | -| `/supabase/.temp/{gotrue,rest,storage,realtime,studio,pgmeta,logflare,pooler}-version` | text | always — linked-project per-service image version pins, written by `supabase link`; absent/unreadable resolves to the embedded default image | -| `~/.docker/config.json` | JSON | via the `docker`/`podman` CLI itself, for registry auth — never read directly by this process | +| Path | Format | When | +| ----------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | always | +| `/supabase/.env`, `.env.local` | dotenv | always (`.env.local` skipped when `SUPABASE_ENV=test`) | +| project-root / `SUPABASE_ENV`-selected dotenv file | dotenv | always, same precedence chain as `stop`/`status` | +| `auth.signing_keys_path` file | JSON | when configured | +| `api.tls.cert_path` / `api.tls.key_path` | PEM | when `api.tls.enabled` | +| `auth.email.template.*` / `auth.email.notification.*` content files | text | when configured | +| GCP JWT credentials file | JSON | when `analytics.backend = "bigquery"` | +| `/supabase/roles.sql` | SQL | on a fresh volume (custom-roles seed) — the "Seeding globals..." message always prints first; the file itself is only read if it exists, tolerating a missing file | +| `/supabase/migrations/*.sql`, `supabase/seed.sql` | SQL | on a fresh volume, via the standard migration-apply + seed pipeline | +| `/supabase/` (files/directories/globs) | SQL | on a fresh volume, INSTEAD of `migrations/*.sql`, when `--experimental`/`SUPABASE_EXPERIMENTAL` is set and `[experimental.pgdelta] enabled` is false | +| `/supabase/.branches/_current_branch` | text | on every start, existence check before writing (see "Files Written") | +| `/supabase/functions/**` | — | when Edge Runtime starts, and independently when Studio starts (function discovery/config resolution + Docker bind mounts, regardless of whether Edge Runtime itself is enabled) | +| `/supabase/.temp/storage-migration` | text | always — linked-project Storage migration pin (`DB_MIGRATIONS_FREEZE_AT`), written by `supabase link`; absent/unreadable resolves to no pin | +| `/supabase/.temp/{gotrue,rest,storage,realtime,studio,pgmeta,logflare,pooler}-version` | text | always — linked-project per-service image version pins, written by `supabase link`; absent/unreadable resolves to the embedded default image. Under `SUPABASE_USE_SLIM_IMAGES`, only a pin that matches the current Dockerfile tag is slim-translated; a historical pin stays on docker.io (those slim tags are not published) | +| `~/.docker/config.json` | JSON | via the `docker`/`podman` CLI itself, for registry auth — never read directly by this process | ## Files Written @@ -103,6 +106,14 @@ 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. +`SUPABASE_USE_SLIM_IMAGES` rewrites image names to `ghcr.io/supabase/cli/*`. Postgres, +storage, and edge-runtime share the docker.io container specs (root start, `sh`/`wget`, +`/mnt` and `/root/.cache/deno` mounts). Distroless slim services with no `/bin/sh` +(auth, studio, pg-meta) omit Docker healthchecks — `docker create --health-cmd` is +always `CMD-SHELL` — and `legacyCheckContainerReady` treats `Running` as ready. +The same flag keeps Vector's image entrypoint (`vector --config /etc/vector/vector.yaml`) +and copies `vector.yaml` via `secretFiles` instead of a `sh` heredoc. Realtime and +analytics keep a busybox `wget --spider` probe. 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 +148,7 @@ 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` — 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 +170,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..2462482d95 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,7 +5,7 @@ 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"; @@ -130,6 +130,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 +350,50 @@ describe("legacyStartEdgeRuntimeContainer", () => { }), ); + it.effect( + "slim edge-runtime uses the docker.io entrypoint, /root main service, and shared cache volume", + () => + 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).toContain("--entrypoint"); + expect(createArgs).toContain("sh"); + const script = createArgs.at(-1); + expect(script).toContain("--main-service=/root"); + expect(script).not.toContain("--main-service=/tmp"); + + const volumeCreate = mock.calls.find((call) => call.args[0] === "volume"); + expect(volumeCreate?.args.at(-1)).toBe("supabase_edge_runtime_proj"); + expect(createArgs).not.toContain("supabase_edge_runtime_slim_proj:/home/nonroot:rw"); + + 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(["root/index.ts"]); + }), + ); + 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..55d283180f 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,8 @@ 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 { ramInBytes } from "../../../shared/legacy-size-units.ts"; import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; import { legacyStartInternalDbUrl, @@ -150,6 +150,10 @@ export function legacyBuildStorageEnv(input: LegacyStorageEnvInput): Record { + vi.unstubAllEnvs(); +}); + const baseEnvInput: LegacyStorageEnvInput = { targetMigration: "", anonKey: "anon-key", @@ -253,4 +257,30 @@ describe("legacyBuildStorageContainerSpec", () => { "postgresql://postgres:postgres@supabase_db_proj:5432/postgres", ); }); + + test("emits the wget Docker healthcheck on a slim 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?.test).toEqual([ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:5000/status", + ]); + }); + + test("mounts the named volume at /mnt on a slim image", () => { + 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:/mnt"]); + expect(spec.env["FILE_STORAGE_BACKEND_PATH"]).toBe("/mnt"); + }); }); 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/supavisor.service.ts b/apps/cli/src/legacy/commands/start/services/supavisor.service.ts index d7aeb89a30..e2d0a45ac5 100644 --- a/apps/cli/src/legacy/commands/start/services/supavisor.service.ts +++ b/apps/cli/src/legacy/commands/start/services/supavisor.service.ts @@ -40,6 +40,10 @@ 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"; import { legacyRenderStartPoolerExs, type LegacyStartPoolerExsFields, @@ -180,20 +184,23 @@ export function legacyBuildSupavisorContainerSpec( { containerPort: LEGACY_SUPAVISOR_TRANSACTION_PORT }, ], ports: [{ hostPort: String(input.port), containerPort: dockerPort }], - healthcheck: { - test: [ - "CMD", - "curl", - "-sSfL", - "--head", - "-o", - "/dev/null", - "http://127.0.0.1:4000/api/health", - ], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - }, + // The slim supavisor image is distroless plus /bin/busybox (no curl). + healthcheck: legacyUsesSlimRuntime(input.image) + ? legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:4000/api/health") + : { + test: [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "http://127.0.0.1:4000/api/health", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, restartPolicy: "unless-stopped", networkId: input.networkId, networkAliases: [LEGACY_SUPAVISOR_CONTAINER_SUFFIX], 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..07fcea01c1 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -1790,9 +1790,10 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // the same typed config error every other malformed-config path in // this handler already produces, matching the fail-fast-at-decode // behavior every other field validates with. + const resolvedServiceImage = resolveImage(image); const { spec, excludeFromHealthWatch } = yield* buildSpecForService( entry.service, - resolveImage(image), + resolvedServiceImage, ).pipe( Effect.catchDefect((defect) => Effect.fail( @@ -1980,18 +1981,26 @@ 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, + images: started, + }), + ).pipe(Effect.result); if (Result.isFailure(healthResult)) { const error = healthResult.failure; if (flags.ignoreHealthCheck && legacyIsUnhealthyStartError(error)) { @@ -2012,10 +2021,10 @@ 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], { + 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..23fbd53b4b 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,6 +2648,9 @@ 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)); }); 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..a5f4b6b4f1 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,12 +1,25 @@ 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 { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; +import type { LocalServiceVersionOverrides } from "../../../shared/services/services.shared.ts"; +import { toSlimImage } from "../../../shared/services/slim-images.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"; +const currentGotrue = dockerfileServiceImageRaw("gotrue"); +const currentLogflare = dockerfileServiceImageRaw("logflare"); +const currentVector = dockerfileServiceImageRaw("vector"); +const currentPooler = dockerfileServiceImageRaw("supavisor"); +const currentPoolerTag = currentPooler.split(":")[1] ?? ""; + describe("LEGACY_START_SERVICES", () => { it("has one row per LEGACY_SERVICE_CATALOG entry, in the catalog's startOrder", () => { expect(LEGACY_START_SERVICES).toHaveLength(LEGACY_SERVICE_CATALOG.length); @@ -212,3 +225,49 @@ 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(currentGotrue); + expect(imageFor("vector")).toBe(currentVector); + 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(toSlimImage("gotrue", currentGotrue)); + expect(imageFor("logflare")).toBe(toSlimImage("logflare", currentLogflare)); + expect(imageFor("vector")).toBe(toSlimImage("vector", currentVector)); + expect(imageFor("supavisor", { pooler: currentPoolerTag })).toBe( + toSlimImage("supavisor", currentPooler), + ); + expect(imageFor("supavisor", { pooler: "2.0.0" })).toBe("supabase/supavisor:2.0.0"); + expect(imageFor("kong")).toBe("library/kong:2.8.1"); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts b/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts new file mode 100644 index 0000000000..96546a0360 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts @@ -0,0 +1,160 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeAll, describe, expect, test } from "vitest"; + +import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; +import { toSlimImage } from "../../../shared/services/slim-images.ts"; +import { ensureImage, resolveDeadline } from "../../../../tests/helpers/docker-image.ts"; +import { + overrideStackPorts, + requireCliSuccess, + runSupabase, +} from "../../../../tests/helpers/cli.ts"; +import { + legacySanitizeProjectId, + legacyServiceContainerName, + localDbContainerId, +} from "../../shared/legacy-docker-ids.ts"; + +const execFileAsync = promisify(execFile); + +const START_TIMEOUT_MS = 280_000; +const SHORT_E2E_TIMEOUT_MS = 30_000; +const PULL_TIMEOUT_MS = 240_000; +const LIFECYCLE_OVERHEAD_MS = 90_000; + +const SLIM_ENV = { SUPABASE_USE_SLIM_IMAGES: "1" } as const; +/** Override an inherited dogfood/CI flag so docker.io starts stay on docker.io. */ +const DOCKER_IO_ENV = { SUPABASE_USE_SLIM_IMAGES: "" } as const; +const START_ARGS = ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"]; +const PULL_ALIASES = [ + "pg", + "gotrue", + "postgrest", + "realtime", + "storage", + "edgeruntime", + "pgmeta", + "mailpit", + "kong", +] as const; + +function latestImagesToPull(): ReadonlyArray { + const images = new Set(); + for (const alias of PULL_ALIASES) { + const raw = dockerfileServiceImageRaw(alias); + images.add(raw); + const slim = toSlimImage(alias, raw); + if (slim.startsWith("ghcr.io/supabase/cli/")) images.add(slim); + } + return [...images]; +} + +function readSectionPort(config: string, section: string): number { + const escaped = section.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = new RegExp(`^\\[${escaped}\\][\\s\\S]*?^port = (\\d+)`, "m").exec(config); + if (match?.[1] === undefined) { + throw new Error(`missing [${section}] port`); + } + return Number(match[1]); +} +async function containerImage(name: string): Promise { + const { stdout } = await execFileAsync("docker", [ + "inspect", + name, + "--format", + "{{.Config.Image}}", + ]); + return stdout.trim(); +} + +function expectedSlimImage(alias: string): string { + return toSlimImage(alias, dockerfileServiceImageRaw(alias)); +} + +async function pullLatestImage(image: string, deadline: number): Promise { + try { + await execFileAsync("docker", ["pull", image], { + timeout: Math.max(1, deadline - Date.now()), + }); + } catch { + await ensureImage(image, deadline); + } +} + +describe("supabase start slim images (e2e)", () => { + let projectDir: string | undefined; + + beforeAll(async () => { + const deadline = resolveDeadline(PULL_TIMEOUT_MS); + for (const image of latestImagesToPull()) { + await pullLatestImage(image, deadline); + } + }, PULL_TIMEOUT_MS + 10_000); + + afterEach(async () => { + if (projectDir === undefined) return; + await runSupabase(["stop", "--no-backup"], { + entrypoint: "legacy", + cwd: projectDir, + env: SLIM_ENV, + }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + projectDir = undefined; + }); + + test( + "starts the latest slim images, serves a function without a version pin, and keeps the Dockerfile tag", + { timeout: START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-slim-start-e2e-")); + const projectId = legacySanitizeProjectId(path.basename(projectDir)); + const edgeRuntimeContainer = legacyServiceContainerName("edge_runtime", projectId); + const dbContainer = localDbContainerId(projectId); + const storageContainer = legacyServiceContainerName("storage", projectId); + + const init = await runSupabase(["init"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, + env: DOCKER_IO_ENV, + }); + requireCliSuccess(init, "init"); + + const created = await runSupabase(["functions", "new", "hello", "--auth", "none"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, + env: { ...DOCKER_IO_ENV, SUPABASE_YES: "1" }, + }); + requireCliSuccess(created, "functions new"); + await overrideStackPorts(projectDir); + const config = await readFile(path.join(projectDir, "supabase", "config.toml"), "utf8"); + const apiPort = readSectionPort(config, "api"); + + const start = await runSupabase(START_ARGS, { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + env: SLIM_ENV, + }); + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + + expect(await containerImage(dbContainer)).toBe(expectedSlimImage("pg")); + expect(await containerImage(storageContainer)).toBe(expectedSlimImage("storage")); + expect(await containerImage(edgeRuntimeContainer)).toBe(expectedSlimImage("edgeruntime")); + + const invoked = await fetch(`http://127.0.0.1:${apiPort}/functions/v1/hello`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Functions" }), + }); + const body = await invoked.text(); + expect(invoked.ok, body).toBe(true); + expect(JSON.parse(body)).toEqual({ message: "Hello Functions!" }); + }, + ); +}); 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 a48d38eead..31c83a4aa1 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -19,7 +19,11 @@ * 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 Realtime still runs the one-shot + * (user migrations need the tenant before long-running containers boot). + * Slim Storage has no `migrate-call.js`, so that job uses the docker.io + * storage image. 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 @@ -802,6 +806,9 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( const dbPassword = legacyStartInternalDbPassword(input.dbUrl); if (input.config.realtime.enabled) { + // Slim realtime's ENTRYPOINT (`tini` + `/app/entry.sh`) migrates, seeds + // when `SEED_SELF_HOST=true`, then `exec "$@"`. Passing only `cmd` (no + // entrypoint override) runs that one-shot before user migrations. yield* legacyRunStartMigrateJob(spawner, { image: input.images.realtime, networkId: input.networkId, 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 5af2ee4ed0..d734a9091b 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,41 @@ describe("legacyStartSetupLocalDatabase", () => { ); }); + it.effect( + "slim refs: runs realtime, storage, and auth one-shots on the resolved slim images", + () => { + 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/realtime:v2.129.3", + "ghcr.io/supabase/cli/storage:v1.70.3", + "ghcr.io/supabase/cli/auth:v2.196.0", + ]); + expect(docker.runs[0]?.cmd?.[0]).toBe("/app/bin/realtime"); + expect(docker.runs[1]?.cmd).toEqual(["node", "dist/scripts/migrate-call.js"]); + expect(docker.runs[2]?.cmd).toEqual(["gotrue", "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/pinned-image.ts b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts index 89b70d18f1..0d417735bb 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts @@ -1,8 +1,8 @@ -import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; -import { - replaceImageTag, - type LocalServiceVersionName, - type LocalServiceVersionOverrides, +import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; +import { slimImageForCurrentPin } from "../../../shared/services/slim-images.ts"; +import type { + LocalServiceVersionName, + LocalServiceVersionOverrides, } from "../../../shared/services/services.shared.ts"; /** @@ -18,13 +18,18 @@ import { * start`'s own native container bootstrap became a second caller across the * `start`/`db` family boundary, see `apps/cli/CLAUDE.md`'s "Hoist Before You * Duplicate" rule. + * + * Slim-translate only the current Dockerfile pin. A historical `.temp` pin + * stays on docker.io — those slim tags are not published. */ export function legacyResolvePinnedImage( alias: string, localServiceName: LocalServiceVersionName, serviceVersions: LocalServiceVersionOverrides, ): string { - const baseImage = dockerfileServiceImage(alias); - const pinnedVersion = serviceVersions[localServiceName]; - return pinnedVersion === undefined ? baseImage : replaceImageTag(baseImage, pinnedVersion); + return slimImageForCurrentPin( + alias, + dockerfileServiceImageRaw(alias), + serviceVersions[localServiceName], + ); } 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..57bf26555b --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.unit.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; +import { toSlimImage } from "../../../shared/services/slim-images.ts"; +import { legacyResolvePinnedImage } from "./pinned-image.ts"; + +const currentTag = (alias: string) => dockerfileServiceImageRaw(alias).split(":")[1] ?? ""; +const currentAuth = dockerfileServiceImageRaw("gotrue"); +const currentAuthTag = currentTag("gotrue"); +const currentPooler = dockerfileServiceImageRaw("supavisor"); +const currentPoolerTag = currentTag("supavisor"); +const currentPostgres = dockerfileServiceImageRaw("pg"); +const currentPostgresTag = currentTag("pg"); + +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(currentAuth); + 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 and the pin is current", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(legacyResolvePinnedImage("gotrue", "auth", {})).toBe(toSlimImage("gotrue", currentAuth)); + expect(legacyResolvePinnedImage("gotrue", "auth", { auth: currentAuthTag })).toBe( + toSlimImage("gotrue", currentAuth), + ); + }); + + it("keeps a historical pin on docker.io", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(legacyResolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" })).toBe( + "supabase/gotrue:v2.100.0", + ); + expect(legacyResolvePinnedImage("storage", "storage", { storage: "v1.67.0" })).toBe( + "supabase/storage-api:v1.67.0", + ); + expect(legacyResolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" })).toBe( + "supabase/supavisor:2.0.0", + ); + }); + + it("normalizes a current pooler pin onto the slim tag scheme", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(legacyResolvePinnedImage("supavisor", "pooler", { pooler: currentPoolerTag })).toBe( + toSlimImage("supavisor", currentPooler), + ); + expect( + legacyResolvePinnedImage("supavisor", "pooler", { + pooler: currentPoolerTag.startsWith("v") + ? currentPoolerTag.slice(1) + : `v${currentPoolerTag}`, + }), + ).toBe(toSlimImage("supavisor", currentPooler)); + }); + + it("keeps a historical postgres pin on docker.io", () => { + 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( + "supabase/postgres:17.4.1.1", + ); + expect(legacyResolvePinnedImage("pg", "postgres", { postgres: currentPostgresTag })).toBe( + toSlimImage("pg", currentPostgres), + ); + }); +}); 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..156d56c6da 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts @@ -50,11 +50,31 @@ const LEGACY_POSTGRES_PASSWORD = "postgres"; */ const LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH = "/etc/postgresql-custom/pgsodium_root.key"; +/** + * The post-migration hook path: `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}). + */ +const LEGACY_POSTGRES_SCHEMA_SQL_PATH = "/etc/postgresql.schema.sql"; + /** 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", +]; + /** Go's `utils.DbAliases` (`apps/cli-go/internal/utils/config.go:36`). */ const LEGACY_POSTGRES_NETWORK_ALIASES: ReadonlyArray = ["db", "db.supabase.internal"]; @@ -137,9 +157,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 +165,19 @@ 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, + ); +} + /** * 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 +313,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 +364,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" + @@ -389,6 +420,18 @@ export function legacyBuildPostgresStartContainerSpec( 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 }, + ], + }), binds: [ `${containerName}:/var/lib/postgresql/data`, // Go's `StartDatabase` (`start.go:163`) appends this bind ONLY on the `fromBackup` branch — @@ -401,20 +444,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: LEGACY_POSTGRES_HEALTHCHECK_TEST, intervalSeconds: LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS, timeoutSeconds: LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS, retries: LEGACY_POSTGRES_HEALTHCHECK_RETRIES, @@ -429,11 +461,18 @@ 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. {@link LEGACY_SHADOW_ENTRYPOINT_ARGS} joins it for the + * docker.io entrypoint script's own `` splice point. */ -export const LEGACY_SHADOW_ENTRYPOINT_ARGS = "-c max_worker_processes=0"; +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 = LEGACY_SHADOW_ENTRYPOINT_ARGV.join(" "); /** * Input to {@link legacyBuildShadowPostgresContainerSpec} — the subset of @@ -527,9 +566,6 @@ export function legacyBuildShadowPostgresContainerSpec( env, entrypoint: "sh", cmd: ["-c", script], - binds: [], - autoRemove: true, - ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), ...(isPg14OrEarlier ? {} : { @@ -537,9 +573,12 @@ export function legacyBuildShadowPostgresContainerSpec( { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, ], }), + binds: [], + autoRemove: true, + ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), ports: [{ hostPort: String(input.shadowPort), containerPort: "5432" }], healthcheck: { - test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], + 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..904df95c28 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"; @@ -19,6 +19,10 @@ import { const POSTGRES_CONFIG_HEADER = "\n# supabase [db.settings] configuration\n"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + function baseDb(overrides: Partial = {}): CliConfig["db"] { return { port: 54322, @@ -229,6 +233,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" }]); 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..953617173e --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts @@ -0,0 +1,50 @@ +/** + * 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, studio, pg-meta) 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. Postgres, storage, and + * edge-runtime now match docker.io (root start, `sh`/`wget`), so they share + * the docker.io specs; the flag only rewrites their image names. + */ + +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..7ed9a183dd 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -9,12 +9,13 @@ * * 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). + * 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). * * 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 @@ -160,10 +161,9 @@ export interface LegacyStartDatabaseInput { readonly webhooksEnabled: boolean; readonly setup: LegacyFreshDbSetupInput; /** - * Fired synchronously, exactly once, right after the pre-create volume probe resolves — - * the caller's own equivalent of Go's package-level `utils.NoBackupVolume` global, needed by - * the caller's OWN `legacyRollbackStart` (which this function does NOT call itself — see this - * module's header) even when this function fails partway through, after the probe. + * Caller's `utils.NoBackupVolume` equivalent for `legacyRollbackStart`. Fired once + * after pre-create refuse guards pass. Skipped on those guards so rollback cannot + * treat leftover sibling volumes as this run's fresh data. */ readonly onFreshVolumeResolved: (isFreshVolume: boolean) => void; } @@ -200,13 +200,12 @@ export const legacyStartDatabase = ( // `VolumeInspect` and the guard both run strictly BEFORE `DockerStart`, which is the ONLY // place Go ever creates the network (`docker.go:363-386`). const isFreshVolume = !(yield* legacyVolumeExists(spawner, input.dbContainerId)); - input.onFreshVolumeResolved(isFreshVolume); - const fromBackup = input.postgresSpec.fromBackup; + if (!isFreshVolume && fromBackup !== undefined) { // Go's `StartDatabase` (`start.go:170-172`): a `--from-backup` restore into an // already-provisioned volume is refused outright, BEFORE any container or network is - // created. + // created — and before freshness is published, so rollback cannot prune it. return yield* Effect.fail( new LegacyStartBackupVolumeExistsError({ message: "backup volume already exists", @@ -215,11 +214,8 @@ export const legacyStartDatabase = ( ); } - // Go's `StartDatabase` (`start.go:168-175`) prints this unconditionally to stderr — Go has - // no output-format concept for this seam at all. Matches every other progress line in this - // same pipeline (`db-setup.ts`'s "Initialising schema..."/"Seeding globals...", - // `legacy-migrate-and-seed.ts`'s "Applying migration ..."), which are also unguarded - // (review: PRRT_kwDOErm0O86VmHkn). + // Go prints this before DockerStart (image resolve). Keep that order so a + // flag-off cold/failed pull still follows the established progress line. yield* output.raw( isFreshVolume ? LEGACY_START_STARTING_DATABASE_MESSAGE @@ -229,6 +225,8 @@ export const legacyStartDatabase = ( const resolvedPostgresImage = yield* input.resolvePostgresImage; + input.onFreshVolumeResolved(isFreshVolume); + // 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..7cf2999fa9 100644 --- a/apps/cli/src/legacy/shared/legacy-db-image.ts +++ b/apps/cli/src/legacy/shared/legacy-db-image.ts @@ -1,5 +1,6 @@ import { Effect, type FileSystem, type Path } from "effect"; -import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; +import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; +import { slimImageForCurrentPin } from "../../shared/services/slim-images.ts"; /** * Resolves the local Postgres Docker image the way `config.Load` does, @@ -11,7 +12,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 legacyPgImageRaw = () => dockerfileServiceImageRaw("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,20 +81,9 @@ export const legacyResolveDbImage = Effect.fnUntraced(function* ( ? `supabase/postgres:${orioledbVersion}-orioledb` : `supabase/postgres:orioledb-${orioledbVersion}`; } - let image = LEGACY_PG_IMAGE; - switch (majorVersion) { - case 13: - image = LEGACY_PG15; - break; - case 14: - image = LEGACY_PG14; - break; - case 15: - image = LEGACY_PG15; - break; - default: - break; - } + const usedFallback = majorVersion === 13 || majorVersion === 14 || majorVersion === 15; + let image = usedFallback ? (majorVersion === 14 ? LEGACY_PG14 : LEGACY_PG15) : legacyPgImageRaw(); + let appliedPin: string | undefined; if (majorVersion > 14) { const versionPath = path.join(workdir, "supabase", ".temp", "postgres-version"); const pinned = yield* fs.readFileString(versionPath).pipe( @@ -101,9 +94,14 @@ 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); + appliedPin = pinned; } } } - return image; + // 13/14/15 fallbacks have no slim build. Historical PG17 pins stay docker.io. + if (usedFallback) { + return image; + } + return slimImageForCurrentPin("pg", legacyPgImageRaw(), appliedPin); }); 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..5693970cbb 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,29 @@ -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 { + dockerfileServiceImage, + dockerfileServiceImageRaw, +} from "../../shared/services/dockerfile-images.ts"; +import { toSlimImage } from "../../shared/services/slim-images.ts"; import { legacyResolveDbImage } from "./legacy-db-image.ts"; +const currentPostgres = dockerfileServiceImageRaw("pg"); +const currentPostgresTag = currentPostgres.split(":")[1] ?? ""; + 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 +61,40 @@ 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("keeps a historical default-major pin on docker.io", () => { + 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("supabase/postgres:17.9.9.999"); + rmSync(dir, { recursive: true, force: true }); + }); + }); + + it.effect("rewrites the current Dockerfile pin to the slim registry", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = withTemp(); + writePin(dir, currentPostgresTag); + return Effect.gen(function* () { + expect(yield* resolve(dir, 17)).toBe(toSlimImage("pg", currentPostgres)); + 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..3841ed4d4c 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,10 @@ 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"; +import { slimImageForCurrentPin } from "../../shared/services/slim-images.ts"; /** * Resolves the edge-runtime Docker image the way Go's `config.Load` does @@ -12,40 +17,52 @@ 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 { - const index = image.indexOf(":"); - 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* ( fs: FileSystem.FileSystem, path: Path.Path, workdir: string, denoVersion: number, ) { - let image = LEGACY_EDGE_RUNTIME_IMAGE; + if (denoVersion === 1) { + return LEGACY_EDGE_RUNTIME_DENO1_IMAGE; + } + const raw = dockerfileServiceImageRaw("edgeruntime"); 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); - } - if (denoVersion === 1) { - image = LEGACY_EDGE_RUNTIME_DENO1_IMAGE; + if (pinned === DENO1_EDGE_RUNTIME_VERSION) { + return LEGACY_EDGE_RUNTIME_DENO1_IMAGE; } - return image; + return slimImageForCurrentPin("edgeruntime", raw, pinned.length > 0 ? pinned : undefined); }); + +/** + * 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). Historical pins stay on docker.io — those slim tags are not published. + */ +export const legacyResolveEdgeRuntimeImage = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + denoVersion: number, +) => resolveEdgeRuntimeImage(fs, path, workdir, denoVersion); + +/** + * Same resolution as {@link legacyResolveEdgeRuntimeImage}. The slim image now + * ships `sh`, so shell-entrypoint callers no longer need a docker.io pin. + */ +export const legacyResolveEdgeRuntimeShellImage = legacyResolveEdgeRuntimeImage; 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..babd59c64f 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,22 @@ 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 { + dockerfileServiceImage, + dockerfileServiceImageRaw, +} from "../../shared/services/dockerfile-images.ts"; +import { toSlimImage } from "../../shared/services/slim-images.ts"; +import { + legacyResolveEdgeRuntimeImage, + legacyResolveEdgeRuntimeShellImage, +} from "./legacy-edge-runtime-image.ts"; + +const currentEdgeRuntime = dockerfileServiceImageRaw("edgeruntime"); +const currentEdgeRuntimeTag = currentEdgeRuntime.split(":")[1] ?? ""; const resolve = (workdir: string, denoVersion: number) => Effect.gen(function* () { @@ -15,6 +26,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 +46,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 +83,91 @@ describe("legacyResolveEdgeRuntimeImage", () => { ), ); }); + + describe("with the slim-images flag on", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.effect("keeps a historical pin on docker.io", () => { + 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("supabase/edge-runtime:v9.9.9"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("rewrites the current Dockerfile 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"), + `${currentEdgeRuntimeTag}\n`, + ); + return resolve(dir, 2).pipe( + Effect.tap((image) => + Effect.sync(() => { + expect(image).toBe(toSlimImage("edgeruntime", currentEdgeRuntime)); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("keeps a historical shell pin on docker.io", () => { + 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("rewrites the shell resolver's current 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"), + `${currentEdgeRuntimeTag}\n`, + ); + return resolveShell(dir, 2).pipe( + Effect.tap((image) => + Effect.sync(() => { + expect(image).toBe(toSlimImage("edgeruntime", currentEdgeRuntime)); + 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..462e6896d9 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,26 @@ describe("legacyEdgeRuntimeScriptLayer sentinel handling", () => { }, ); + it.effect("rewrites the runner onto the slim image with the slim-images flag on", () => { + 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).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.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.run.unit.test.ts new file mode 100644 index 0000000000..4cfd6f2e04 --- /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: uses the same bash cmd as docker.io", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const opts = runStreamPgDump(SLIM_IMAGE); + expect(opts.entrypoint).toBeUndefined(); + expect(opts.cmd).toEqual(["bash", "-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/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 1cf21cd163..63252b3c29 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -42,10 +42,10 @@ import { } from "./deploy.errors.ts"; import { buildFunctionsDockerRunArgs, + edgeRuntimeCacheVolume, ensureDockerNamedVolume, ensureDockerNetwork, isDockerRunning, - localDockerId, resolveDockerNetworkMode, resolveEdgeRuntimeVersion, resolveFunctionsDockerImage, @@ -1231,9 +1231,10 @@ export async function buildDockerBinds( }, ]; if (process.env["BITBUCKET_CLONE_DIR"] === undefined) { + const cacheVolume = edgeRuntimeCacheVolume(projectId); binds.unshift({ - hostPath: localDockerId("edge_runtime", projectId), - containerPath: "/root/.cache/deno", + hostPath: cacheVolume.name, + containerPath: cacheVolume.containerPath, mode: "rw", externalScope: false, }); @@ -1423,6 +1424,10 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( }); } const outputPath = join(outputDir, "output.eszip"); + // `edgeRuntimeImage` applies the tag VERBATIM (Go's `replaceImageTag`) + // — a `.temp/edge-runtime-version` pin flows through unmodified, `v` + // prefix or not (see the helper's doc in `functions.shared.ts`). + const rawImage = edgeRuntimeImage(edgeRuntimeVersion); const binds = yield* Effect.promise(() => buildDockerBinds(projectId, functionsDir, outputDir, config, { onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), @@ -1435,15 +1440,9 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( // `PulledEdgeRuntimeImage` is: per-slug matches Go's per-container // `DockerStart` exactly, and the first resolve failure aborts the loop, // so the only cost is one cached `docker image inspect` per function. - const image = yield* resolveFunctionsDockerImage( - // `edgeRuntimeImage` applies the tag VERBATIM (Go's `replaceImageTag`) - // — a `.temp/edge-runtime-version` pin flows through unmodified, `v` - // prefix or not (see the helper's doc in `functions.shared.ts`). - edgeRuntimeImage(edgeRuntimeVersion), - projectEnvValues, - ); + const image = yield* resolveFunctionsDockerImage(rawImage, projectEnvValues); yield* ensureDockerNetwork(networkMode, projectId); - yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId); + yield* ensureDockerNamedVolume(edgeRuntimeCacheVolume(projectId).name, projectId); const env: Array = []; if ( diff --git a/apps/cli/src/shared/functions/deploy.unit.test.ts b/apps/cli/src/shared/functions/deploy.unit.test.ts index 7eb7be3ffb..3f596b108d 100644 --- a/apps/cli/src/shared/functions/deploy.unit.test.ts +++ b/apps/cli/src/shared/functions/deploy.unit.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, realpath, rename, rm, symlink, writeFile } from "node:f import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildDockerBinds, formatDockerBind, type ResolvedDeployFunctionConfig } from "./deploy.ts"; import { FunctionImportNotDirectoryError } from "./deploy.errors.ts"; @@ -645,3 +645,42 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil } }); }); + +describe("buildDockerBinds — edge-runtime Deno-cache volume selection", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("mounts the shared volume at /root/.cache/deno by default", async () => { + const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( + {}, + 'Deno.serve(() => new Response("ok"));\n', + ); + + try { + const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config); + expect(binds.map(formatDockerBind)).toContain( + "supabase_edge_runtime_test-project:/root/.cache/deno:rw", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("mounts the shared /root/.cache/deno volume when the image is slim", async () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( + {}, + 'Deno.serve(() => new Response("ok"));\n', + ); + + try { + const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config); + const formatted = binds.map(formatDockerBind); + expect(formatted).toContain("supabase_edge_runtime_test-project:/root/.cache/deno:rw"); + expect(formatted).not.toContain("supabase_edge_runtime_slim_test-project:/home/nonroot:rw"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index c6f7c1cebe..2b85ba0611 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -18,10 +18,10 @@ import { legacyDescribeContainerCliFailure } from "../../legacy/shared/legacy-co import { legacyViperEnvStringWithProjectFallback } from "../legacy/legacy-viper-env.ts"; import { buildFunctionsDockerRunArgs, + edgeRuntimeCacheVolume, ensureDockerNamedVolume, ensureDockerNetwork, isDockerRunning, - localDockerId, resolveDockerNetworkMode, resolveEdgeRuntimeVersion, resolveFunctionsDockerImage, @@ -48,7 +48,7 @@ const legacyEntrypointPath = "file:///src/index.ts"; // — fixed container-side paths for the docker-unbundle path, unrelated to // deploy's `toDockerPath` host-mirroring scheme. const DOCKER_DENO_DIR = "/home/deno"; -const DOCKER_ESZIP_DIR = "/root/eszips"; +const dockerIoEszipDir = "/root/eszips"; export interface DownloadFunctionsOptions { readonly functionName: Option.Option; @@ -1065,7 +1065,8 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( const { projectId, denoVersion, image, projectEnvValues } = edgeRuntimeImage; const functionsDir = resolve(dependencies.projectRoot, "supabase", "functions"); const hostEszipPath = resolve(eszipPath); - const dockerEszipPath = posix.join(DOCKER_ESZIP_DIR, eszipFileName); + const cacheVolume = edgeRuntimeCacheVolume(projectId); + const dockerEszipPath = posix.join(dockerIoEszipDir, eszipFileName); const dockerOutputPath = posix.join(DOCKER_DENO_DIR, slug); // Go: `viper.GetString("network-id")` else `NetId` (`docker.go:379-383`) — @@ -1091,7 +1092,7 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( yield* ensureDockerNetwork(networkMode, projectId).pipe( Effect.mapError(withLegacyBundleSuggestion(slug, styleAqua)), ); - yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId).pipe( + yield* ensureDockerNamedVolume(cacheVolume.name, projectId).pipe( Effect.mapError(withLegacyBundleSuggestion(slug, styleAqua)), ); @@ -1103,19 +1104,17 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( // environment doesn't allow, same carve-out as `deploy.ts`'s // `buildDockerBinds`. const binds = [ - ...(process.env["BITBUCKET_CLONE_DIR"] === undefined - ? [`${localDockerId("edge_runtime", projectId)}:/root/.cache/deno:rw`] - : []), + ...(process.env["BITBUCKET_CLONE_DIR"] === undefined ? [cacheVolume.bind] : []), `${hostEszipPath}:${dockerEszipPath}:ro`, `${functionsDir}:${DOCKER_DENO_DIR}:rw`, ]; - const command = buildFunctionsDockerRunArgs({ + const spec = { image, projectId, networkMode, binds, containerArgs: ["unbundle", "--eszip", dockerEszipPath, "--output", dockerOutputPath], - }); + }; // Go pipes the container's stdout/stderr straight to `os.Stdout`/`getErrorLogger()` // while the container runs (`DockerRunOnceWithConfig`, copied live via the @@ -1125,41 +1124,50 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( // (`download.go:279`); machine-output modes must keep stdout // payload-only (CLI-1546), so this mirrors `deploy.ts`'s own // `bundleFunctionWithDocker` routing. - const result = yield* runChildProcess("docker", command, { - stdout: "pipe", - stderr: "pipe", - onStdout: (chunk) => output.raw(chunk, output.format === "text" ? "stdout" : "stderr"), - onStderr: (chunk) => output.raw(chunk, "stderr"), - }).pipe( - Effect.mapError( - withDockerStepFailure("failed to run the edge-runtime unbundle container", slug, styleAqua), - ), - ); + const runUnbundleContainer = (command: ReadonlyArray) => + Effect.gen(function* () { + const result = yield* runChildProcess("docker", [...command], { + stdout: "pipe", + stderr: "pipe", + onStdout: (chunk) => output.raw(chunk, output.format === "text" ? "stdout" : "stderr"), + onStderr: (chunk) => output.raw(chunk, "stderr"), + }).pipe( + Effect.mapError( + withDockerStepFailure( + "failed to run the edge-runtime unbundle container", + slug, + styleAqua, + ), + ), + ); - if (result.exitCode !== 0) { - // Go's `getErrorLogger` (deno-v1 only) sets `CmdSuggestion = - // suggestDenoV2()` (assignment) as soon as a full stderr line reads - // "invalid eszip v2" (case-insensitive), then `downloadWithDockerUnbundle` - // appends `suggestLegacyBundle` (`+=`) once extraction has failed - // (`download.go:213,284-304`). Go's own implementation races these two - // goroutines (the pipe writer is never closed) — this resolves that - // race deterministically to the common (non-race) ordering instead of - // reproducing the nondeterminism. The line match is exact (not a - // substring) to match Go's `strings.EqualFold(line, "invalid eszip v2")`. - const invalidEszipV2 = - denoVersion === 1 && - result.stderr - .split(/\r?\n/) - .some((line) => line.trim().toLowerCase() === "invalid eszip v2"); - const suggestion = - (invalidEszipV2 ? suggestDenoV2(styleEmphasis) : "") + suggestLegacyBundle(slug, styleAqua); - return yield* Effect.fail( - Object.assign(new Error(`error running container: exit ${result.exitCode}`), { - suggestion, - }), - ); - } + if (result.exitCode !== 0) { + // Go's `getErrorLogger` (deno-v1 only) sets `CmdSuggestion = + // suggestDenoV2()` (assignment) as soon as a full stderr line reads + // "invalid eszip v2" (case-insensitive), then `downloadWithDockerUnbundle` + // appends `suggestLegacyBundle` (`+=`) once extraction has failed + // (`download.go:213,284-304`). Go's own implementation races these two + // goroutines (the pipe writer is never closed) — this resolves that + // race deterministically to the common (non-race) ordering instead of + // reproducing the nondeterminism. The line match is exact (not a + // substring) to match Go's `strings.EqualFold(line, "invalid eszip v2")`. + const invalidEszipV2 = + denoVersion === 1 && + result.stderr + .split(/\r?\n/) + .some((line) => line.trim().toLowerCase() === "invalid eszip v2"); + const suggestion = + (invalidEszipV2 ? suggestDenoV2(styleEmphasis) : "") + + suggestLegacyBundle(slug, styleAqua); + return yield* Effect.fail( + Object.assign(new Error(`error running container: exit ${result.exitCode}`), { + suggestion, + }), + ); + } + }); + yield* runUnbundleContainer(buildFunctionsDockerRunArgs(spec)); // Go: `downloadWithDockerUnbundle` has no final "Downloaded Function ..." // print, unlike `RunLegacy`/`downloadWithServerSideUnbundle` — its only // stdout/stderr text is "Downloading function: ..." above plus whatever diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 999fc633d1..f8d037a66a 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("\\", "/"); @@ -32,6 +29,21 @@ export function localDockerId(name: string, projectId: string) { return `supabase_${name}_${normalizeProjectId(projectId)}`; } +/** + * The Deno-cache volume bind for an edge-runtime container. Both image + * families now run as root, so the shared `supabase_edge_runtime_` + * volume mounts at `/root/.cache/deno`. + */ +export function edgeRuntimeCacheVolume(projectId: string) { + const name = localDockerId("edge_runtime", projectId); + const containerPath = "/root/.cache/deno"; + return { + name, + containerPath, + bind: `${name}:${containerPath}:rw`, + }; +} + /** * Go: `DockerStart`'s network selection (`internal/utils/docker.go:379-383`) * combined with root's `viper.BindPFlags`/`AutomaticEnv` for the persistent @@ -90,7 +102,7 @@ export function toDockerPath(hostPath: string) { * carry its `0644` default. */ export function containerArchiveBytes( - files: Readonly>, + files: Readonly>, ): Promise { return new Bun.Archive( Object.fromEntries( @@ -134,7 +146,14 @@ export interface FunctionsDockerRunSpec { * associate an orphaned container with the project. */ export function buildFunctionsDockerRunArgs(spec: FunctionsDockerRunSpec): Array { - const command = ["run", "--rm", ...spec.binds.flatMap((bind) => ["-v", bind])]; + return buildFunctionsDockerContainerArgs(["run", "--rm"], spec); +} + +function buildFunctionsDockerContainerArgs( + head: ReadonlyArray, + spec: FunctionsDockerRunSpec, +): Array { + const command = [...head, ...spec.binds.flatMap((bind) => ["-v", bind])]; command.push("--network", spec.networkMode); if ((spec.platform ?? process.platform) === "linux") { command.push("--add-host", "host.docker.internal:host-gateway"); diff --git a/apps/cli/src/shared/functions/functions-docker.unit.test.ts b/apps/cli/src/shared/functions/functions-docker.unit.test.ts index 2270a795ad..76ddb2c83d 100644 --- a/apps/cli/src/shared/functions/functions-docker.unit.test.ts +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -7,6 +7,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { buildFunctionsDockerRunArgs, containerArchiveBytes, + edgeRuntimeCacheVolume, localDockerId, resolveDockerNetworkMode, runChildProcess, @@ -211,6 +212,16 @@ describe("buildFunctionsDockerRunArgs", () => { }); }); +describe("edgeRuntimeCacheVolume", () => { + it("keeps the shared volume at /root/.cache/deno", () => { + expect(edgeRuntimeCacheVolume("my-project")).toEqual({ + name: "supabase_edge_runtime_my-project", + containerPath: "/root/.cache/deno", + bind: "supabase_edge_runtime_my-project:/root/.cache/deno:rw", + }); + }); +}); + describe("containerArchiveBytes", () => { // Regular-file tar entries parsed straight from the ustar headers. function tarRegularFileEntries(archive: Uint8Array): ReadonlyArray<[string, number]> { @@ -245,6 +256,13 @@ describe("containerArchiveBytes", () => { const files = await new Bun.Archive(archive).files(); expect(await files.get("root/index.ts")?.text()).toBe("export const x = 1;\n"); }); + + it("accepts binary file bodies", async () => { + const body = new Uint8Array([0x00, 0x01, 0xfe, 0xff]); + const archive = await containerArchiveBytes({ "/tmp/eszips/output.eszip": body }); + const files = await new Bun.Archive(archive).files(); + expect(new Uint8Array(await files.get("tmp/eszips/output.eszip")!.arrayBuffer())).toEqual(body); + }); }); describe("resolveDockerNetworkMode", () => { diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 63f740c849..243158dd36 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -1,7 +1,8 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { Effect } from "effect"; -import { dockerfileServiceImage } from "../services/dockerfile-images.ts"; +import { dockerfileServiceImageRaw } from "../services/dockerfile-images.ts"; +import { imageTag, slimImageForCurrentPin } from "../services/slim-images.ts"; const functionSlugPattern = /^[A-Za-z][A-Za-z0-9_-]*$/; @@ -27,8 +28,11 @@ 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] ?? ""; +// 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 +46,23 @@ 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}`; + } + return slimImageForCurrentPin("edgeruntime", dockerfileServiceImageRaw("edgeruntime"), tag); } /** @@ -62,6 +79,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 || (imageTag(dockerfileServiceImageRaw("edgeruntime")) ?? "")), ); }); 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..72d94bd4ac --- /dev/null +++ b/apps/cli/src/shared/functions/functions.shared.unit.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Effect } from "effect"; + +import { dockerfileServiceImageRaw } from "../services/dockerfile-images.ts"; +import { + DENO1_EDGE_RUNTIME_VERSION, + edgeRuntimeImage, + resolveEdgeRuntimeVersionPin, +} from "./functions.shared.ts"; + +const rawEdgeRuntimeImage = dockerfileServiceImageRaw("edgeruntime"); +const currentEdgeRuntimeTag = rawEdgeRuntimeImage.slice(rawEdgeRuntimeImage.lastIndexOf(":") + 1); + +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 the current Dockerfile tag onto the slim ghcr.io image when the flag is on", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(edgeRuntimeImage(currentEdgeRuntimeTag)).toBe( + `ghcr.io/supabase/cli/edge-runtime:${currentEdgeRuntimeTag}`, + ); + }); + + it("keeps a historical pin on docker.io when the flag is on", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(edgeRuntimeImage("v1.73.0")).toBe("supabase/edge-runtime:v1.73.0"); + }); + + 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}`, + ); + }); +}); + +describe("resolveEdgeRuntimeVersionPin", () => { + it("falls back to the Dockerfile tag, not the ghcr host, when slim is on and no pin file exists", async () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const tag = await Effect.runPromise(resolveEdgeRuntimeVersionPin("/no-such-supabase-dir")); + expect(tag).toBe(currentEdgeRuntimeTag); + expect(tag.includes("/")).toBe(false); + }); + + it("falls back to the Dockerfile tag while the flag is off", async () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + const tag = await Effect.runPromise(resolveEdgeRuntimeVersionPin("/no-such-supabase-dir")); + expect(tag).toBe(currentEdgeRuntimeTag); + }); +}); 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.ts b/apps/cli/src/shared/functions/serve.ts index a763e2ae78..f5252fd7a6 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -75,6 +75,7 @@ import { import { containerArchiveBytes, dockerProjectLabels, + edgeRuntimeCacheVolume, ensureDockerNamedVolume, ensureDockerNetwork, localDockerId, @@ -119,7 +120,7 @@ const ignoredDirNames = new Set([ const dockerLogRetryDelay = Duration.millis(400); const dockerLogDiagnosticTailLength = 4_096; const defaultSupabaseEnv = "development"; -const serveMainContainerPath = "/root/index.ts"; +const serveMainDir = "/root"; const shellVariableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/; let cachedLegacyFunctionsServeMainTemplate: string | undefined; const watchIgnoreGlobs = [ @@ -1664,7 +1665,6 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo const watchableBinds = new Map(); const emittedScopeWarnings = new Set(); const functionsConfig: Record = {}; - for (const config of functionConfigs) { if (!config.enabled) { yield* output.raw(`Skipped serving Function: ${config.slug}\n`, "stderr"); @@ -1717,7 +1717,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo const binds = [...functionBinds.values()]; - yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId); + yield* ensureDockerNamedVolume(edgeRuntimeCacheVolume(projectId).name, projectId); yield* ensureDockerNetwork(networkMode, projectId); const env = [ @@ -1770,10 +1770,11 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo }); const labels = dockerProjectLabels(projectId); + 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), @@ -1784,7 +1785,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo // `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); 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..85547c6b55 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -14,6 +14,7 @@ import { parseDockerfileServiceImages, type DockerfileImageSpec, } from "./dockerfile-images.ts"; +import { slimImageForAlias, slimImageForCurrentPin, slimImagesEnabled } from "./slim-images.ts"; export { parseDockerfileServiceImages } from "./dockerfile-images.ts"; @@ -38,6 +39,12 @@ export interface LocalServiceImageOptions { readonly imageOverrides?: LocalServiceImageOverrides; readonly normalizeVersionTags?: boolean; readonly serviceVersions?: LocalServiceVersionOverrides; + /** + * Legacy `.temp` pins only slim-translate when they match the current + * Dockerfile tag (unpublished historical slim tags). Next start runs + * catalog versions from GHCR, so it leaves this off. + */ + readonly slimCurrentPinOnly?: boolean; } // Mirrors Go's `utils.ProjectRefPattern` (`apps/cli-go/internal/utils/misc.go`). @@ -47,6 +54,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 +99,7 @@ function localServiceImagesFromSpecs( } return { + alias: service.alias, image, remoteService: service.remoteService, localService: service.localService, @@ -120,7 +129,7 @@ export function postgresImageForDbMajorVersion(majorVersion: number): string | u } } -export function replaceImageTag(image: string, tag: string): string { +function replaceImageTag(image: string, tag: string): string { const index = image.lastIndexOf(":"); if (index === -1) { return image; @@ -141,12 +150,33 @@ 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) { + return { + ...service, + image: options.slimCurrentPinOnly + ? slimImageForCurrentPin(service.alias, service.image, version) + : slimImageForAlias( + service.alias, + replaceImageTag( + service.image, + normalizeVersionTags + ? tagForServiceVersion(service.localService, version) + : 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..14c43d8ead 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,67 @@ 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("keeps historical pins on docker.io when slimCurrentPinOnly is set", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect( + listLocalServiceVersions({ + slimCurrentPinOnly: true, + serviceVersions: { pooler: "2.0.0", analytics: "1.4.0" }, + }), + ).toEqual( + expect.arrayContaining([ + { name: "supabase/supavisor", local: "2.0.0", remote: "" }, + { name: "supabase/logflare", local: "1.4.0", remote: "" }, + ]), + ); + }); + + test("slim-translates catalog version overrides that are not the Dockerfile pin", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(listLocalServiceVersions({ serviceVersions: { storage: "v1.70.3" } })).toContainEqual({ + name: "ghcr.io/supabase/cli/storage", + local: "v1.70.3", + 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..ae37549c15 --- /dev/null +++ b/apps/cli/src/shared/services/slim-images.ts @@ -0,0 +1,131 @@ +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; +} + +export function imageTag(image: string): string | undefined { + const tagSeparator = image.lastIndexOf(":"); + return tagSeparator === -1 ? undefined : image.slice(tagSeparator + 1); +} + +function replaceImageTag(image: string, tag: string): string { + const tagSeparator = image.lastIndexOf(":"); + return tagSeparator === -1 ? image : `${image.slice(0, tagSeparator + 1)}${tag}`; +} + +/** + * True when `pin` catalog-normalizes to the same slim tag as `currentRawImage`. + * Historical `.temp` pins that would become unpublished slim tags return false. + */ +export function pinMatchesCurrentImage( + alias: string, + pin: string, + currentRawImage: string, +): boolean { + const currentTag = imageTag(currentRawImage); + if (currentTag === undefined) { + return false; + } + const service = SLIM_SERVICE_BY_ALIAS[alias]; + if (service === undefined) { + return pin.trim() === currentTag; + } + return dockerImageForService(service, pin) === dockerImageForService(service, currentTag); +} + +/** + * Apply an optional `.temp` pin to the docker.io Dockerfile ref, then + * slim-translate only when the flag is on and the pin is absent or current. + */ +export function slimImageForCurrentPin( + alias: string, + currentRawImage: string, + pin?: string, +): string { + const trimmed = pin?.trim() ?? ""; + const tagged = trimmed.length > 0 ? replaceImageTag(currentRawImage, trimmed) : currentRawImage; + if (!slimImagesEnabled()) { + return tagged; + } + if (trimmed.length > 0 && !pinMatchesCurrentImage(alias, trimmed, currentRawImage)) { + return tagged; + } + return toSlimImage(alias, tagged); +} + +/** 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. + */ +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..a8b868bc34 --- /dev/null +++ b/apps/cli/src/shared/services/slim-images.unit.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { dockerfileServiceImageRaw, dockerfileServiceImages } from "./dockerfile-images.ts"; +import { + pinMatchesCurrentImage, + slimImageForAlias, + slimImageForCurrentPin, + 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); + }); +}); + +describe("pinMatchesCurrentImage", () => { + it("treats catalog-equivalent pooler tags as current", () => { + const current = dockerfileServiceImageRaw("supavisor"); + const currentTag = current.split(":")[1] ?? ""; + const altTag = currentTag.startsWith("v") ? currentTag.slice(1) : `v${currentTag}`; + expect(pinMatchesCurrentImage("supavisor", currentTag, current)).toBe(true); + expect(pinMatchesCurrentImage("supavisor", altTag, current)).toBe(true); + expect(pinMatchesCurrentImage("supavisor", "2.0.0", current)).toBe(false); + }); +}); + +describe("slimImageForCurrentPin", () => { + it("slim-translates the current pin and leaves a historical pin on docker.io", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const current = dockerfileServiceImageRaw("storage"); + const currentTag = current.split(":")[1] ?? ""; + expect(slimImageForCurrentPin("storage", current)).toBe(toSlimImage("storage", current)); + expect(slimImageForCurrentPin("storage", current, currentTag)).toBe( + toSlimImage("storage", current), + ); + expect(slimImageForCurrentPin("storage", current, "v1.67.0")).toBe( + "supabase/storage-api:v1.67.0", + ); + }); + + it("is a no-op while the flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + const current = dockerfileServiceImageRaw("storage"); + expect(slimImageForCurrentPin("storage", current, "v1.67.0")).toBe( + "supabase/storage-api:v1.67.0", + ); + }); +}); 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 ef7152183c..4ae3d710a8 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" },