From 92576b123351f1822f7f9b345610f598d0d7169e Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 25 Aug 2026 18:51:27 +0200 Subject: [PATCH 01/24] feat(cli): add SUPABASE_USE_SLIM_IMAGES flag for slim ghcr images When SUPABASE_USE_SLIM_IMAGES is "true" or "1", the legacy shell resolves its local-stack Docker images from the slim ghcr.io/supabase/cli/ builds instead of the docker.io images pinned in the embedded Dockerfile manifest, keeping the Dockerfile version pins translated into the slim tag scheme via @supabase/stack's catalog (new "./versions" package export). Services with no slim build keep their docker.io reference: kong, the differ/migra/pgprove job images, the pg 13/14/15 major-version fallbacks, OrioleDB overrides, and the deno_version = 1 edge-runtime pin. Slim refs bypass the SUPABASE_INTERNAL_IMAGE_REGISTRY/ECR rewrite (they exist only at ghcr.io/supabase/cli), status/--exclude short names stay flag-invariant, services-command image overrides are never translated, and the declarative stale-container guard now rejects slim/non-slim family mismatches. With the flag unset, behavior is byte-identical. Co-Authored-By: Claude Fable 5 --- .../legacy/commands/bootstrap/SIDE_EFFECTS.md | 25 ++-- .../legacy/commands/db/diff/SIDE_EFFECTS.md | 1 + .../legacy/commands/db/dump/SIDE_EFFECTS.md | 15 +-- .../legacy/commands/db/push/SIDE_EFFECTS.md | 1 + .../legacy/commands/db/reset/SIDE_EFFECTS.md | 1 + .../legacy-pgdelta.seam.integration.test.ts | 45 ++++++- .../db/shared/legacy-pgdelta.seam.layer.ts | 10 +- .../legacy/commands/db/start/SIDE_EFFECTS.md | 41 +++--- .../commands/functions/deploy/SIDE_EFFECTS.md | 1 + .../functions/download/SIDE_EFFECTS.md | 1 + .../commands/functions/serve/SIDE_EFFECTS.md | 1 + .../legacy/commands/gen/types/SIDE_EFFECTS.md | 27 ++-- .../commands/migration/squash/SIDE_EFFECTS.md | 2 +- .../src/legacy/commands/start/SIDE_EFFECTS.md | 27 ++-- .../start/start.services.unit.test.ts | 52 +++++++- .../shared/db-bootstrap/pinned-image.ts | 12 +- .../db-bootstrap/pinned-image.unit.test.ts | 53 ++++++++ apps/cli/src/legacy/shared/legacy-db-image.ts | 9 +- .../shared/legacy-db-image.unit.test.ts | 35 +++++- .../legacy/shared/legacy-docker-registry.ts | 16 +++ .../legacy-docker-registry.unit.test.ts | 48 +++++++ .../shared/legacy-edge-runtime-image.ts | 11 +- .../src/legacy/shared/legacy-status-values.ts | 21 ++-- .../shared/legacy-status-values.unit.test.ts | 39 +++++- .../src/shared/functions/functions-docker.ts | 5 +- .../src/shared/functions/functions.shared.ts | 28 ++++- .../functions/functions.shared.unit.test.ts | 28 +++++ .../functions/serve-main-offline.e2e.test.ts | 8 +- .../src/shared/services/dockerfile-images.ts | 14 ++- .../src/shared/services/services.shared.ts | 16 ++- .../services/services.shared.unit.test.ts | 55 +++++++- apps/cli/src/shared/services/slim-images.ts | 72 +++++++++++ .../shared/services/slim-images.unit.test.ts | 117 ++++++++++++++++++ apps/cli/tests/helpers/legacy-mocks.ts | 21 ++++ packages/stack/package.json | 1 + 35 files changed, 754 insertions(+), 105 deletions(-) create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/pinned-image.unit.test.ts create mode 100644 apps/cli/src/shared/functions/functions.shared.unit.test.ts create mode 100644 apps/cli/src/shared/services/slim-images.ts create mode 100644 apps/cli/src/shared/services/slim-images.unit.test.ts diff --git a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md index 462c02f1fd..fb2cf58426 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` | legacy opt-out's edge-runtime image resolves from the slim `ghcr.io/supabase/cli/edge-runtime` build; ambient shell only, unlike the neighboring project-`.env`-aware vars | 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/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index fdf1627eb5..d1139cba1f 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` | resolves the pg-delta edge-runtime image from the slim `ghcr.io/supabase/cli/edge-runtime` build for the cache export; ambient shell only, unlike the neighboring project-`.env`-aware vars | 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..e9bde67484 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` | resolves the pg-delta edge-runtime image from the slim `ghcr.io/supabase/cli/edge-runtime` build for the migrations-catalog cache export; ambient shell only, unlike the neighboring project-`.env`-aware vars | 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/shared/legacy-pgdelta.seam.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts index ec0927af3c..78e16e634e 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 { mockLegacyCliConfig, @@ -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 401d8c5c6d..dcd258821c 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 { @@ -256,7 +257,14 @@ export const legacyDeclarativeSeamLayer = Layer.effect( const expected = legacyGetRegistryImageUrl(image).trim(); const actualTag = dockerImageTag(actual); const expectedTag = dockerImageTag(expected); - if (actualTag.length === 0 || expectedTag.length === 0 || actualTag === expectedTag) { + // Slim refs never go through a registry mirror, so a family mismatch + // (e.g. a docker.io container satisfying a ghcr.io/supabase/cli + // expectation) is stale even when the tags happen to match. + const familyMismatch = isSlimImageRef(expected) !== isSlimImageRef(actual); + if ( + !familyMismatch && + (actualTag.length === 0 || expectedTag.length === 0 || actualTag === expectedTag) + ) { return; } return yield* Effect.fail( diff --git a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md index 88457f78aa..ac0e68469f 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -106,26 +106,27 @@ native container command in this codebase — never `supabase-go`. ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_PROJECT_ID` | overrides the local container id | no | -| `SUPABASE_DB_PORT` | overrides `db.port` (the published host port) | no | -| `SUPABASE_DB_MAJOR_VERSION` | overrides `db.major_version` (image selection, schema branch) | no | -| `SUPABASE_DB_HEALTH_TIMEOUT` | overrides `db.health_timeout` | no | -| `SUPABASE_DB_SETTINGS_*` | overrides individual `[db.settings]` fields | no | -| `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` | overrides `experimental.orioledb_version` (image + env) | no | -| `SUPABASE_EXPERIMENTAL_S3_{HOST,REGION,ACCESS_KEY,SECRET_KEY}` | OrioleDB S3 env overrides | no | -| `SUPABASE_REALTIME_ENABLED` | gates the fresh-volume realtime migrate job | no | -| `SUPABASE_REALTIME_IP_VERSION` / `_MAX_HEADER_LENGTH` | realtime migrate job env overrides | no | -| `SUPABASE_STORAGE_ENABLED` | gates the fresh-volume storage migrate job | no | -| `SUPABASE_STORAGE_FILE_SIZE_LIMIT` | storage migrate job env override | no | -| `SUPABASE_AUTH_ENABLED` | gates the fresh-volume auth migrate job | no | -| `SUPABASE_AUTH_EXTERNAL_URL` / `SUPABASE_AUTH_SITE_URL` | auth migrate job env overrides | no | -| `SUPABASE_AUTH_JWT_EXPIRY` | Postgres's `JWT_EXP` env / signing | no | -| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache warmup (unset/unrecognized defaults to the next engine, which skips it) | no | -| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file, installed into the process environment before any Docker work) to pick the Docker daemon this whole command talks to | no | +| Variable | Purpose | Required? | +| -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_PROJECT_ID` | overrides the local container id | no | +| `SUPABASE_USE_SLIM_IMAGES` | resolves every service image from the slim `ghcr.io/supabase/cli/` builds instead of the docker.io Dockerfile pins (`true`/`1` enable); the pg 13/14/15 fallbacks and OrioleDB images 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/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md index cdbc9b26a6..8ac96cd816 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md @@ -53,6 +53,7 @@ Docker bundling may pull or run the configured edge-runtime image and uses the | `SUPABASE_PROJECT_ID` | optional project ref fallback; also read from project dotenv now (previously ambient-shell-only) | no | | `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the Functions bundler image registry; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no | +| `SUPABASE_USE_SLIM_IMAGES` | resolves the Functions bundler image from the slim `ghcr.io/supabase/cli/edge-runtime` build (`true`/`1` enable); ambient shell only, unlike the neighboring registry override | no | | `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | | `BITBUCKET_CLONE_DIR` | when set, skips creating the named Deno-cache volume and omits its bind mount from the bundler `docker run` (Bitbucket's restricted Docker environment rejects both); a project-dotenv-only value is installed into `process.env` by config loading | no | | `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which bundler image tag to use) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | diff --git a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md index 7889051f10..fd5c97e485 100644 --- a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md @@ -67,6 +67,7 @@ to stderr in machine-output modes (CLI-1546). | `SUPABASE_ENV` | Docker-unbundle path: selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | | `BITBUCKET_CLONE_DIR` | Docker-unbundle path: when set, skips creating the named Deno-cache volume and omits its bind mount from the `docker run` command (Bitbucket's restricted Docker environment rejects both) | no | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the registry the edge-runtime unbundle image is pulled from (`legacyGetRegistryImageUrl`); read from the ambient shell **or** project dotenv (Docker-unbundle path); unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL — also consumed on the `--use-api` invocation even though it never pulls an image | no (defaults to `public.ecr.aws`) | +| `SUPABASE_USE_SLIM_IMAGES` | Docker-unbundle path: resolves the edge-runtime unbundle image from the slim `ghcr.io/supabase/cli/edge-runtime` build (`true`/`1` enable); `deno_version = 1` keeps its docker.io image | no | | `SUPABASE_NETWORK_ID` | Docker-unbundle path: overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | | `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | Docker-unbundle path: overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | diff --git a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md index de9ec466e7..874c8dd6d1 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 | diff --git a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md index 6441e6b534..b753ab500d 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -58,19 +58,20 @@ timeout. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | local Docker container and network project ID | no (falls back to the workdir name) | -| `SUPABASE_DB_PORT` | local database probe port | no (defaults to `54322`) | -| `SUPABASE_DB_MAJOR_VERSION` | local PostgreSQL major version | no (defaults to `17`) | -| `SUPABASE_API_SCHEMAS` | local schemas used when `--schema` is omitted | no (defaults to `public,graphql_public`) | -| `SUPABASE_ENV` | selects nested dotenv files for local generation | no (defaults to `development`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_DB_PASSWORD` | database password for `--local` and the `--linked` workdir project | no (defaults to `postgres`; **ignored** for ad-hoc `--project-id`, which always mints a temporary login role) | -| `SUPABASE_SERVICES_HOSTNAME` | host used for the local TLS probe | no (defaults to `127.0.0.1`) | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | pg-meta image registry override (`docker.io` → Docker Hub; any other value → that registry) | no (defaults to the ECR registry) | -| `SUPABASE_CA_SKIP_VERIFY` | when `true`, prints a TLS-verification-disabled warning to stderr | no | +| Variable | Purpose | Required? | +| ---------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | local Docker container and network project ID | no (falls back to the workdir name) | +| `SUPABASE_DB_PORT` | local database probe port | no (defaults to `54322`) | +| `SUPABASE_DB_MAJOR_VERSION` | local PostgreSQL major version | no (defaults to `17`) | +| `SUPABASE_API_SCHEMAS` | local schemas used when `--schema` is omitted | no (defaults to `public,graphql_public`) | +| `SUPABASE_ENV` | selects nested dotenv files for local generation | no (defaults to `development`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_DB_PASSWORD` | database password for `--local` and the `--linked` workdir project | no (defaults to `postgres`; **ignored** for ad-hoc `--project-id`, which always mints a temporary login role) | +| `SUPABASE_SERVICES_HOSTNAME` | host used for the local TLS probe | no (defaults to `127.0.0.1`) | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | pg-meta image registry override (`docker.io` → Docker Hub; any other value → that registry) | no (defaults to the ECR registry) | +| `SUPABASE_USE_SLIM_IMAGES` | resolves the pg-meta image from the slim `ghcr.io/supabase/cli/pgmeta` build (`true`/`1` enable) | no | +| `SUPABASE_CA_SKIP_VERIFY` | when `true`, prints a TLS-verification-disabled warning to stderr | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md index cced4a7655..89548d5f5c 100644 --- a/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md @@ -65,7 +65,7 @@ migration-history table to match. `SUPABASE_YES`, `DB_PASSWORD`, `SUPABASE_ACCESS_TOKEN`, `SUPABASE_SERVICES_HOSTNAME`, `DOCKER_HOST`/`DOCKER_CONTEXT`/`DOCKER_CONFIG`, `SUPABASE_NETWORK_ID`, -`SUPABASE_INTERNAL_IMAGE_REGISTRY`, `SUPABASE_PROJECT_ID`, `SUPABASE_DEBUG`, +`SUPABASE_INTERNAL_IMAGE_REGISTRY`, `SUPABASE_USE_SLIM_IMAGES`, `SUPABASE_PROJECT_ID`, `SUPABASE_DEBUG`, `SUPABASE_EXPERIMENTAL`. ## Exit Codes diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 5ccad7bb53..f36768a434 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -159,19 +159,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 `LegacyCliConfig.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, 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 `LegacyCliConfig.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/start.services.unit.test.ts b/apps/cli/src/legacy/commands/start/start.services.unit.test.ts index fec1cd789c..e769c0ae04 100644 --- a/apps/cli/src/legacy/commands/start/start.services.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/start.services.unit.test.ts @@ -1,10 +1,15 @@ import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { LocalServiceVersionOverrides } from "../../../shared/services/services.shared.ts"; import { legacyServiceContainerIds, localDbContainerId } from "../../shared/legacy-docker-ids.ts"; import { LEGACY_SERVICE_CATALOG } from "../../shared/legacy-service-catalog.ts"; -import { legacyResolveStartGates, type LegacyStartGates } from "./start.gates.ts"; +import { + legacyResolveStartGates, + legacyResolveStartImagePlan, + type LegacyStartGates, +} from "./start.gates.ts"; import { LEGACY_START_SERVICES, legacyStartServiceMeta } from "./start.services.ts"; describe("LEGACY_START_SERVICES", () => { @@ -212,3 +217,46 @@ describe("LEGACY_START_SERVICES enabledGate cross-check against start.gates.ts", expect(ungated.map((entry) => entry.service).toSorted()).toEqual(["postgres"]); }); }); + +describe("legacyResolveStartImagePlan under SUPABASE_USE_SLIM_IMAGES", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + const allGatesOpen: LegacyStartGates = { + kong: true, + gotrue: true, + mailpit: true, + realtime: true, + postgrest: true, + storage: true, + imgproxy: true, + logflare: true, + vector: true, + pgMeta: true, + studio: true, + supavisor: true, + edgeRuntime: true, + }; + + const imageFor = (service: string, serviceVersions: LocalServiceVersionOverrides = {}) => + legacyResolveStartImagePlan(allGatesOpen, serviceVersions).find( + (entry) => entry.service === service, + )?.image; + + it("plans docker.io images while the flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(imageFor("gotrue")).toBe("supabase/gotrue:v2.196.0"); + expect(imageFor("vector")).toBe("timberio/vector:0.53.0-alpine"); + expect(imageFor("supavisor", { pooler: "2.0.0" })).toBe("supabase/supavisor:2.0.0"); + }); + + it("plans slim images when the flag is on, keeping unmapped services on docker.io", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(imageFor("gotrue")).toBe("ghcr.io/supabase/cli/auth:v2.196.0"); + expect(imageFor("logflare")).toBe("ghcr.io/supabase/cli/analytics:v1.50.4"); + expect(imageFor("vector")).toBe("ghcr.io/supabase/cli/vector:0.53.0"); + expect(imageFor("supavisor", { pooler: "2.0.0" })).toBe("ghcr.io/supabase/cli/pooler:v2.0.0"); + expect(imageFor("kong")).toBe("library/kong:2.8.1"); + }); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts index 89b70d18f1..c36ae05714 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts @@ -1,4 +1,6 @@ +import { dockerImageForService } from "@supabase/stack/versions"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { replaceImageTag, type LocalServiceVersionName, @@ -26,5 +28,13 @@ export function legacyResolvePinnedImage( ): string { const baseImage = dockerfileServiceImage(alias); const pinnedVersion = serviceVersions[localServiceName]; - return pinnedVersion === undefined ? baseImage : replaceImageTag(baseImage, pinnedVersion); + if (pinnedVersion === undefined) { + return baseImage; + } + // A verbatim tag swap would be wrong on slim refs whose tag scheme differs + // from docker.io's (`pooler`/`analytics` pins are unprefixed on docker.io but + // `v`-prefixed under `ghcr.io/supabase/cli`), so let the catalog normalize. + return slimImagesEnabled() + ? dockerImageForService(localServiceName, pinnedVersion) + : replaceImageTag(baseImage, pinnedVersion); } diff --git a/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.unit.test.ts new file mode 100644 index 0000000000..54efe8a308 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.unit.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { legacyResolvePinnedImage } from "./pinned-image.ts"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("legacyResolvePinnedImage", () => { + it("resolves docker.io images while the slim flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(legacyResolvePinnedImage("gotrue", "auth", {})).toBe("supabase/gotrue:v2.196.0"); + expect(legacyResolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" })).toBe( + "supabase/gotrue:v2.100.0", + ); + expect(legacyResolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" })).toBe( + "supabase/supavisor:2.0.0", + ); + }); + + it("resolves slim images when the flag is on", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(legacyResolvePinnedImage("gotrue", "auth", {})).toBe( + "ghcr.io/supabase/cli/auth:v2.196.0", + ); + expect(legacyResolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" })).toBe( + "ghcr.io/supabase/cli/auth:v2.100.0", + ); + }); + + // A pin written to `supabase/.temp/-version` follows docker.io's tag + // scheme, which is unprefixed for these two while their slim tags are not. + it("normalizes pooler and analytics pins onto the slim tag scheme", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(legacyResolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" })).toBe( + "ghcr.io/supabase/cli/pooler:v2.0.0", + ); + expect(legacyResolvePinnedImage("logflare", "analytics", { analytics: "1.4.0" })).toBe( + "ghcr.io/supabase/cli/analytics:v1.4.0", + ); + }); + + it("keeps the postgres pin path stable across the flag", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(legacyResolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" })).toBe( + "supabase/postgres:17.4.1.1", + ); + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + expect(legacyResolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" })).toBe( + "ghcr.io/supabase/cli/postgres:17.4.1.1", + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-db-image.ts b/apps/cli/src/legacy/shared/legacy-db-image.ts index c7b7ec3690..369755780b 100644 --- a/apps/cli/src/legacy/shared/legacy-db-image.ts +++ b/apps/cli/src/legacy/shared/legacy-db-image.ts @@ -11,7 +11,10 @@ import { dockerfileServiceImage } from "../../shared/services/dockerfile-images. * into `config.Images`, so the TS port tracks Dependabot bumps in that source. */ -const LEGACY_PG_IMAGE = dockerfileServiceImage("pg"); +// Read per call, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is +// observed by the resolver (and by tests that stub the env). +const legacyPgImage = () => dockerfileServiceImage("pg"); +// Major-version fallbacks and the OrioleDB tags below have no slim build. const LEGACY_PG14 = "supabase/postgres:14.1.0.89"; const LEGACY_PG15 = "supabase/postgres:15.8.1.085"; @@ -77,7 +80,7 @@ export const legacyResolveDbImage = Effect.fnUntraced(function* ( ? `supabase/postgres:${orioledbVersion}-orioledb` : `supabase/postgres:orioledb-${orioledbVersion}`; } - let image = LEGACY_PG_IMAGE; + let image = legacyPgImage(); switch (majorVersion) { case 13: image = LEGACY_PG15; @@ -101,7 +104,7 @@ export const legacyResolveDbImage = Effect.fnUntraced(function* ( const colon = image.indexOf(":"); const currentTag = colon >= 0 ? image.slice(colon + 1) : image; if (versionCompare(currentTag, "15.1.0.55") >= 0) { - image = replaceImageTag(LEGACY_PG_IMAGE, pinned); + image = replaceImageTag(image, pinned); } } } diff --git a/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts index f74184da96..190202036a 100644 --- a/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts @@ -1,15 +1,22 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Path } from "effect"; +import { afterEach, vi } from "vitest"; import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; import { legacyResolveDbImage } from "./legacy-db-image.ts"; const withTemp = () => mkdtempSync(join(tmpdir(), "legacy-db-image-")); +const writePin = (workdir: string, pinned: string) => { + const dir = join(workdir, "supabase", ".temp"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "postgres-version"), pinned); +}; + const resolve = (workdir: string, majorVersion: number, orioledbVersion?: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -47,4 +54,30 @@ describe("legacyResolveDbImage", () => { rmSync(dir, { recursive: true, force: true }); }); }); + + describe("pinned version with the slim-images flag on", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.effect("keeps a 13/14/15 fallback on docker.io, not the slim registry", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = withTemp(); + writePin(dir, "15.8.1.100"); + return Effect.gen(function* () { + expect(yield* resolve(dir, 15)).toBe("supabase/postgres:15.8.1.100"); + rmSync(dir, { recursive: true, force: true }); + }); + }); + + it.effect("rewrites the default major's pin to the slim registry", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = withTemp(); + writePin(dir, "17.9.9.999"); + return Effect.gen(function* () { + expect(yield* resolve(dir, 17)).toBe("ghcr.io/supabase/cli/postgres:17.9.9.999"); + rmSync(dir, { recursive: true, force: true }); + }); + }); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-docker-registry.ts b/apps/cli/src/legacy/shared/legacy-docker-registry.ts index eda19ae402..04e029ed55 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-registry.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-registry.ts @@ -12,7 +12,16 @@ * When no registry override is configured, callers that can retry pulls should * use `legacyGetRegistryImageUrlCandidates`: ECR stays the fast default, with * GHCR and the source image as fallbacks for transient registry throttling. + * + * Slim images (`isSlimImageRef`) skip every rewrite below and pull from where + * they exist: both helpers key their rewrite on an image's LAST path segment, + * which would turn `ghcr.io/supabase/cli/postgres:…` into the unrelated + * non-slim `…/supabase/postgres:…` mirror. There is no mirror to redirect + * slim refs to, hence `SUPABASE_INTERNAL_IMAGE_REGISTRY` does not apply to + * them either. */ +import { isSlimImageRef } from "../../shared/services/slim-images.ts"; + const LEGACY_INTERNAL_IMAGE_REGISTRY_ENV = "SUPABASE_INTERNAL_IMAGE_REGISTRY"; const DEFAULT_REGISTRY = "public.ecr.aws"; const DEFAULT_SUPABASE_REGISTRY = `${DEFAULT_REGISTRY}/supabase`; @@ -57,6 +66,9 @@ export function legacyGetRegistryImageUrl( imageName: string, projectEnvValues?: Readonly>, ): string { + if (isSlimImageRef(imageName)) { + return imageName; + } const registry = legacyGetRegistry(projectEnvValues); if (registry === DOCKER_HUB_REGISTRY) { return imageName; @@ -68,6 +80,10 @@ export function legacyGetRegistryImageUrlCandidates( imageName: string, projectEnvValues?: Readonly>, ): ReadonlyArray { + if (isSlimImageRef(imageName)) { + return [imageName]; + } + if (legacyGetRegistryOverride(projectEnvValues) !== undefined) { return [legacyGetRegistryImageUrl(imageName, projectEnvValues)]; } diff --git a/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts index b1c81c8ee2..d44acc32e5 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts @@ -122,4 +122,52 @@ describe("legacyGetRegistryImageUrl", () => { ), ).toBe("merged.example/supabase/pg_prove:3.36"); }); + + // Slim images are published only under `ghcr.io/supabase/cli`. Rewriting them + // by last path segment would silently pull the unrelated non-slim mirror, and + // no mirror of them exists for a registry override to point at. + const SLIM_IMAGE = "ghcr.io/supabase/cli/postgres:17.6.1.165"; + + it("leaves a slim image unrewritten, whatever the registry override says", () => { + for (const registry of [undefined, "public.ecr.aws", "docker.io", "my.mirror.example"]) { + expect(withRegistry(registry, () => legacyGetRegistryImageUrl(SLIM_IMAGE))).toBe(SLIM_IMAGE); + } + expect( + withRegistry(undefined, () => + legacyGetRegistryImageUrl(SLIM_IMAGE, { + SUPABASE_INTERNAL_IMAGE_REGISTRY: "my.mirror.example", + }), + ), + ).toBe(SLIM_IMAGE); + }); + + it("plans a single pull candidate for a slim image", () => { + for (const registry of [undefined, "public.ecr.aws", "docker.io", "my.mirror.example"]) { + expect(withRegistry(registry, () => legacyGetRegistryImageUrlCandidates(SLIM_IMAGE))).toEqual( + [SLIM_IMAGE], + ); + } + expect( + withRegistry(undefined, () => + legacyGetRegistryImageUrlCandidates(SLIM_IMAGE, { + SUPABASE_INTERNAL_IMAGE_REGISTRY: "my.mirror.example", + }), + ), + ).toEqual([SLIM_IMAGE]); + }); + + it("still rewrites the non-slim ghcr.io/supabase namespace", () => { + expect( + withRegistry("docker.io", () => legacyGetRegistryImageUrl("ghcr.io/supabase/postgres:17.6")), + ).toBe("ghcr.io/supabase/postgres:17.6"); + expect( + withRegistry(undefined, () => + legacyGetRegistryImageUrlCandidates("ghcr.io/supabase/postgres:17.6"), + ), + ).toEqual([ + "public.ecr.aws/supabase/postgres:17.6", + "ghcr.io/supabase/postgres:17.6", + "supabase/postgres:17.6", + ]); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts index 6523ac2a7d..25a2cd7401 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts @@ -12,8 +12,11 @@ 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`. +// 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. const LEGACY_EDGE_RUNTIME_DENO1_IMAGE = "supabase/edge-runtime:v1.68.4"; /** `pkg/config/utils.go:81` — replace everything after the first `:` with `tag`. */ @@ -35,14 +38,14 @@ export const legacyResolveEdgeRuntimeImage = Effect.fnUntraced(function* ( workdir: string, denoVersion: number, ) { - let image = LEGACY_EDGE_RUNTIME_IMAGE; + let image = legacyEdgeRuntimeImage(); 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); + image = replaceImageTag(legacyEdgeRuntimeImage(), pinned); } if (denoVersion === 1) { image = LEGACY_EDGE_RUNTIME_DENO1_IMAGE; diff --git a/apps/cli/src/legacy/shared/legacy-status-values.ts b/apps/cli/src/legacy/shared/legacy-status-values.ts index dac58e3fd6..9e71db805e 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 { ProjectConfig } 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 71935639c5..5fd460a469 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 { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { legacyShortContainerImageName, @@ -766,6 +766,43 @@ describe("legacyStatusValues", () => { }); }); +// `--exclude` short names are the established contract, so they must stay on the +// docker.io repo names even when the stack itself runs slim `ghcr.io/supabase/cli` +// images. Re-imports the module so the flag is in effect while its +// image-name constants are built. +describe("--exclude image short names under SUPABASE_USE_SLIM_IMAGES", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("keeps matching the docker.io short names", async () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + vi.resetModules(); + const slimModule = await import("./legacy-status-values.ts"); + + for (const [excluded, omitted] of [ + ["gotrue", "ANON_KEY"], + ["storage-api", "STORAGE_S3_URL"], + ["kong", "API_URL"], + ["mailpit", "MAILPIT_URL"], + ["postgrest", "REST_URL"], + ["studio", "STUDIO_URL"], + ["edge-runtime", "FUNCTIONS_URL"], + ] as const) { + const { values } = slimModule.legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [excluded], + NO_OVERRIDES, + WORKDIR, + ); + expect(values[omitted], `--exclude ${excluded}`).toBeUndefined(); + } + }); +}); + describe("legacyShortContainerImageName", () => { it("extracts the repo name between the first slash and the last colon", () => { expect(legacyShortContainerImageName("supabase/storage-api:v1.61.9")).toBe("storage-api"); diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 999fc633d1..24cfa2bc5d 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -9,13 +9,10 @@ import { Effect, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { spawnContainerCli } from "../../legacy/shared/legacy-container-cli.ts"; import { legacyMakeDockerImageResolver } from "../../legacy/shared/legacy-docker-image-resolve.ts"; +import { DENO1_EDGE_RUNTIME_VERSION } from "./functions.shared.ts"; const INVALID_PROJECT_ID = /[^a-zA-Z0-9_.-]+/g; const MAX_PROJECT_ID_LENGTH = 40; -// Go's `deno1` image tag (`pkg/config/constants.go:15`, -// `supabase/edge-runtime:v1.68.4`) — a full tag, since tags flow verbatim -// into `edgeRuntimeImage` (`functions.shared.ts`) with no `v` synthesis. -const DENO1_EDGE_RUNTIME_VERSION = "v1.68.4"; export function toSlash(pathname: string) { return pathname.replaceAll("\\", "/"); diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 63f740c849..32dc8e5c29 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -27,8 +27,15 @@ export const FUNCTIONS_BUNDLER_MUTEX_GROUP = ["use-api", "use-docker", "legacy-b // reads the same source) — sourced from there rather than `@supabase/stack`'s // independently-maintained catalog, so a Dockerfile pin bump can never drift // from what the `functions` Docker paths resolve. -const DEFAULT_EDGE_RUNTIME_IMAGE = dockerfileServiceImage("edgeruntime"); -const DEFAULT_EDGE_RUNTIME_TAG = DEFAULT_EDGE_RUNTIME_IMAGE.split(":")[1] ?? ""; +// Read per call, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is +// observed by every resolution (and by tests that stub the env). +const defaultEdgeRuntimeImage = () => dockerfileServiceImage("edgeruntime"); + +// Go's `deno1` image tag (`pkg/config/constants.go:15`, +// `supabase/edge-runtime:v1.68.4`) — a full tag, since tags flow verbatim +// into `edgeRuntimeImage` with no `v` synthesis. Shared with +// `functions-docker.ts`'s `resolveEdgeRuntimeVersion`, which selects it. +export const DENO1_EDGE_RUNTIME_VERSION = "v1.68.4"; /** * Go: `replaceImageTag(Images.EdgeRuntime, tag)` (`pkg/config/utils.go:81-84`) @@ -42,10 +49,21 @@ 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. */ export function edgeRuntimeImage(tag: string): string { - const index = DEFAULT_EDGE_RUNTIME_IMAGE.indexOf(":"); - return DEFAULT_EDGE_RUNTIME_IMAGE.slice(0, index + 1) + tag.trim(); + if (tag === DENO1_EDGE_RUNTIME_VERSION) { + return `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`; + } + const base = defaultEdgeRuntimeImage(); + const index = base.indexOf(":"); + return base.slice(0, index + 1) + tag.trim(); } /** @@ -62,6 +80,6 @@ export const resolveEdgeRuntimeVersionPin = Effect.fnUntraced(function* (supabas ).pipe( Effect.map((version) => version.trim()), Effect.catch(() => Effect.succeed("")), - Effect.map((version) => version || DEFAULT_EDGE_RUNTIME_TAG), + Effect.map((version) => version || (defaultEdgeRuntimeImage().split(":")[1] ?? "")), ); }); diff --git a/apps/cli/src/shared/functions/functions.shared.unit.test.ts b/apps/cli/src/shared/functions/functions.shared.unit.test.ts new file mode 100644 index 0000000000..9b16ed2c03 --- /dev/null +++ b/apps/cli/src/shared/functions/functions.shared.unit.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { DENO1_EDGE_RUNTIME_VERSION, edgeRuntimeImage } from "./functions.shared.ts"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("edgeRuntimeImage", () => { + it("keeps the deno1 tag on the docker.io image even when the slim flag is on", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(edgeRuntimeImage(DENO1_EDGE_RUNTIME_VERSION)).toBe( + `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`, + ); + }); + + it("rewrites a non-deno1 tag onto the slim ghcr.io image when the flag is on", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(edgeRuntimeImage("v1.74.3")).toBe("ghcr.io/supabase/cli/edge-runtime:v1.74.3"); + }); + + it("keeps the deno1 tag on the docker.io image while the flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(edgeRuntimeImage(DENO1_EDGE_RUNTIME_VERSION)).toBe( + `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`, + ); + }); +}); diff --git a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts index 6945a2d8f7..d67188e633 100644 --- a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts +++ b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { LEGACY_START_KONG_YML_TEMPLATE } from "../../legacy/commands/start/templates/kong.yml.ts"; -import { LEGACY_EDGE_RUNTIME_IMAGE } from "../../legacy/shared/legacy-edge-runtime-image.ts"; +import { legacyEdgeRuntimeImage } from "../../legacy/shared/legacy-edge-runtime-image.ts"; import { ensureImage, resolveDeadline } from "../../../tests/helpers/docker-image.ts"; import { dockerfileServiceImage } from "../services/dockerfile-images.ts"; import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; @@ -145,7 +145,7 @@ describe("functions serve runtime template (offline)", () => { "boots under edge-runtime with networking disabled and fetches nothing remote", { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, async () => { - const runtimeImage = await ensureImage(LEGACY_EDGE_RUNTIME_IMAGE); + const runtimeImage = await ensureImage(legacyEdgeRuntimeImage()); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-offline-e2e-")); const container = `supabase-serve-offline-e2e-${process.pid.toString()}`; try { @@ -209,7 +209,7 @@ describe("functions serve runtime template (offline)", () => { "returns canonical JWT auth failures", { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, async () => { - const runtimeImage = await ensureImage(LEGACY_EDGE_RUNTIME_IMAGE); + const runtimeImage = await ensureImage(legacyEdgeRuntimeImage()); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-auth-e2e-")); const container = `supabase-serve-auth-e2e-${process.pid.toString()}`; try { @@ -293,7 +293,7 @@ describe("functions serve runtime template (offline)", () => { async () => { const imageDeadline = resolveDeadline(); const [runtimeImage, kongImage] = await Promise.all([ - ensureImage(LEGACY_EDGE_RUNTIME_IMAGE, imageDeadline), + ensureImage(legacyEdgeRuntimeImage(), imageDeadline), ensureImage(dockerfileServiceImage("kong"), imageDeadline), ]); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-kong-e2e-")); diff --git a/apps/cli/src/shared/services/dockerfile-images.ts b/apps/cli/src/shared/services/dockerfile-images.ts index d9982ddf9f..2b9bbd032c 100644 --- a/apps/cli/src/shared/services/dockerfile-images.ts +++ b/apps/cli/src/shared/services/dockerfile-images.ts @@ -1,4 +1,5 @@ import serviceImagesDockerfile from "../../../../cli-go/pkg/config/templates/Dockerfile" with { type: "text" }; +import { slimImageForAlias } from "./slim-images.ts"; export interface DockerfileImageSpec { readonly alias: string; @@ -30,7 +31,8 @@ export function parseDockerfileServiceImages( export const dockerfileServiceImages = parseDockerfileServiceImages(serviceImagesDockerfile); -export function dockerfileServiceImage(alias: string): string { +/** The docker.io reference exactly as pinned in the Dockerfile manifest. */ +export function dockerfileServiceImageRaw(alias: string): string { const service = dockerfileServiceImages.find((image) => image.alias === alias); if (service === undefined) { throw new Error(`Missing service image alias '${alias}' in Dockerfile manifest.`); @@ -38,3 +40,13 @@ export function dockerfileServiceImage(alias: string): string { return service.image; } + +/** + * The default image for `alias`, rewritten to its slim `ghcr.io/supabase/cli` + * equivalent when `SUPABASE_USE_SLIM_IMAGES` is set. This is the single choke + * point for default service images; use `dockerfileServiceImageRaw` where the + * docker.io identity itself is the contract (user-facing short names). + */ +export function dockerfileServiceImage(alias: string): string { + return slimImageForAlias(alias, dockerfileServiceImageRaw(alias)); +} diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 56e5aa666d..0b0e42be63 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -1,5 +1,6 @@ import { styleText } from "node:util"; import { makeApiClient, type ApiClient } from "@supabase/api/effect"; +import { dockerImageForService } from "@supabase/stack/versions"; import { Data, Duration, Effect, Exit, Redacted } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -14,6 +15,7 @@ import { parseDockerfileServiceImages, type DockerfileImageSpec, } from "./dockerfile-images.ts"; +import { slimImageForAlias, slimImagesEnabled } from "./slim-images.ts"; export { parseDockerfileServiceImages } from "./dockerfile-images.ts"; @@ -47,6 +49,7 @@ export interface LocalServiceImageOptions { const PROJECT_REF_PATTERN = /^[a-z]{20}$/; interface ServiceImageSpec { + readonly alias: string; readonly image: string; readonly remoteService: RemoteServiceName | undefined; readonly localService: LocalServiceVersionName; @@ -91,6 +94,7 @@ function localServiceImagesFromSpecs( } return { + alias: service.alias, image, remoteService: service.remoteService, localService: service.localService, @@ -141,12 +145,22 @@ function localServiceImagesForOptions( options: LocalServiceImageOptions = {}, ): ReadonlyArray { const normalizeVersionTags = options.normalizeVersionTags ?? true; + const slim = slimImagesEnabled(); return LOCAL_SERVICE_IMAGES.map((service) => { - const baseImage = options.imageOverrides?.[service.localService] ?? service.image; + // An explicit `imageOverrides` entry is a caller-chosen ref (the Postgres + // major-version fallback, a configured edge-runtime image) with no slim + // counterpart, so it keeps the docker.io path even with the flag on. + const override = options.imageOverrides?.[service.localService]; + const baseImage = override ?? slimImageForAlias(service.alias, service.image); const version = options.serviceVersions?.[service.localService]; if (version === undefined || version.trim().length === 0) { return baseImage === service.image ? service : { ...service, image: baseImage }; } + if (override === undefined && slim) { + // The catalog owns the slim tag scheme, which differs from docker.io's for + // `pooler`/`analytics`; a verbatim tag swap would miss the `v` prefix. + return { ...service, image: dockerImageForService(service.localService, version) }; + } return { ...service, image: replaceImageTag( diff --git a/apps/cli/src/shared/services/services.shared.unit.test.ts b/apps/cli/src/shared/services/services.shared.unit.test.ts index bb343ee103..480647eb78 100644 --- a/apps/cli/src/shared/services/services.shared.unit.test.ts +++ b/apps/cli/src/shared/services/services.shared.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { Effect, Redacted } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import serviceImagesDockerfile from "../../../../cli-go/pkg/config/templates/Dockerfile" with { type: "text" }; @@ -20,6 +20,10 @@ const runLinkedFetch = (input: Parameters[0]) Effect.runPromise(fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer))); describe("services shared", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + test("parses service images from Dockerfile FROM aliases", () => { expect( parseDockerfileServiceImages(` @@ -68,6 +72,55 @@ describe("services shared", () => { ]); }); + test("lists slim images when SUPABASE_USE_SLIM_IMAGES is set", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(listLocalServiceVersions().map((row) => row.name)).toEqual([ + "ghcr.io/supabase/cli/postgres", + "ghcr.io/supabase/cli/auth", + "ghcr.io/supabase/cli/postgrest", + "ghcr.io/supabase/cli/realtime", + "ghcr.io/supabase/cli/storage", + "ghcr.io/supabase/cli/edge-runtime", + "ghcr.io/supabase/cli/studio", + "ghcr.io/supabase/cli/pgmeta", + "ghcr.io/supabase/cli/analytics", + "ghcr.io/supabase/cli/pooler", + ]); + }); + + test("applies pins on the slim tag scheme when SUPABASE_USE_SLIM_IMAGES is set", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect( + listLocalServiceVersions({ serviceVersions: { pooler: "2.0.0", analytics: "1.4.0" } }), + ).toEqual( + expect.arrayContaining([ + { name: "ghcr.io/supabase/cli/pooler", local: "v2.0.0", remote: "" }, + { name: "ghcr.io/supabase/cli/analytics", local: "v1.4.0", remote: "" }, + ]), + ); + }); + + // The Postgres major-version fallback and a configured edge-runtime image are + // caller-chosen refs with no slim counterpart, so the flag must not touch them. + test("leaves explicit image overrides on docker.io when SUPABASE_USE_SLIM_IMAGES is set", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const rows = listLocalServiceVersions({ + imageOverrides: { + postgres: "supabase/postgres:15.8.1.085", + "edge-runtime": "supabase/edge-runtime:v1.68.4", + }, + normalizeVersionTags: false, + serviceVersions: { postgres: "15.8.1.090" }, + }); + + expect(rows).toEqual( + expect.arrayContaining([ + { name: "supabase/postgres", local: "15.8.1.090", remote: "" }, + { name: "supabase/edge-runtime", local: "v1.68.4", remote: "" }, + ]), + ); + }); + test("can preserve raw local service version overrides", () => { expect( listLocalServiceVersions({ diff --git a/apps/cli/src/shared/services/slim-images.ts b/apps/cli/src/shared/services/slim-images.ts new file mode 100644 index 0000000000..809d982df1 --- /dev/null +++ b/apps/cli/src/shared/services/slim-images.ts @@ -0,0 +1,72 @@ +import { dockerImageForService, type ServiceName } from "@supabase/stack/versions"; + +const SLIM_IMAGES_ENV = "SUPABASE_USE_SLIM_IMAGES"; +const SLIM_IMAGE_PREFIX = "ghcr.io/supabase/cli/"; + +/** + * Maps embedded-Dockerfile aliases onto the slim service catalog. Aliases with + * no slim build (kong, the `differ`/`migra`/`pgprove` job images) are absent and + * keep their docker.io reference. + */ +const SLIM_SERVICE_BY_ALIAS: Readonly> = { + pg: "postgres", + gotrue: "auth", + postgrest: "postgrest", + realtime: "realtime", + storage: "storage", + edgeruntime: "edge-runtime", + studio: "studio", + pgmeta: "pgmeta", + logflare: "analytics", + supavisor: "pooler", + vector: "vector", + imgproxy: "imgproxy", + mailpit: "mailpit", +}; + +/** + * Ambient process env only — the project-dotenv installers + * (`legacy-db-config.toml-read.ts`, `legacy-local-project-context.ts`) copy + * only a fixed set of keys into `process.env`, not arbitrary flags, so a + * value set only in `supabase/.env` is not observed here. Read per call + * rather than cached so tests can stub the ambient env per case. + */ +export function slimImagesEnabled(): boolean { + const value = process.env[SLIM_IMAGES_ENV]; + return value === "true" || value === "1"; +} + +/** + * Rewrites a docker.io image reference to its `ghcr.io/supabase/cli` slim + * equivalent, keeping the Dockerfile's pinned version. The catalog owns tag + * normalization (`v`-prefixing, `tagPrefix`), so pins that differ only in + * prefix between the two registries (`supavisor`, `logflare`) land on the right + * slim tag. Vector's docker.io tags carry an `-alpine` variant suffix that the + * slim build does not publish, so the strip is scoped to `vector` only — an + * `-alpine`-suffixed pin on any other service is a real tag, not a variant marker. + */ +export function toSlimImage(alias: string, image: string): string { + const service = SLIM_SERVICE_BY_ALIAS[alias]; + if (service === undefined) { + return image; + } + + const tagSeparator = image.lastIndexOf(":"); + if (tagSeparator === -1) { + return image; + } + + const rawTag = image.slice(tagSeparator + 1); + const tag = alias === "vector" ? rawTag.replace(/-alpine$/, "") : rawTag; + return dockerImageForService(service, tag); +} + +/** `toSlimImage` behind the feature flag; a no-op while the flag is off. */ +export function slimImageForAlias(alias: string, image: string): string { + return slimImagesEnabled() ? toSlimImage(alias, image) : image; +} + +/** Slim images are published only under this prefix; single home for the check. */ +export function isSlimImageRef(image: string): boolean { + return image.startsWith(SLIM_IMAGE_PREFIX); +} 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..b48da0efac --- /dev/null +++ b/apps/cli/src/shared/services/slim-images.unit.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { dockerfileServiceImages } from "./dockerfile-images.ts"; +import { slimImageForAlias, slimImagesEnabled, toSlimImage } 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.7")).toBe( + "ghcr.io/supabase/cli/pooler:v2.9.7", + ); + 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", + ); + }); +}); diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 8ca009133f..a4dca8fa99 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -916,6 +916,10 @@ const LEGACY_SHADOW_STARTING_STATE = * exclusive with `dbInspectFailsWith`, which instead reports a daemon-unreachable failure * (`legacyIsDockerDaemonUnreachable`) with the given stderr text — enforced below (a test * that sets both throws immediately, rather than one option silently winning). + * + * `dbInspectImage` makes the same `supabase_db_`-prefixed inspect report a `Config.Image` + * value instead — for `ensureLocalPostgresImageCurrent`'s stale-image guard, which reads + * that field from the same call `legacyIsLocalDbRunning` only checks the exit code of. */ export function mockLegacyShadowContainerCliSpawner( opts: { @@ -924,6 +928,7 @@ export function mockLegacyShadowContainerCliSpawner( readonly failRemove?: boolean; readonly dbNotRunning?: boolean; readonly dbInspectFailsWith?: string; + readonly dbInspectImage?: string; } = {}, ): { readonly layer: Layer.Layer; @@ -982,6 +987,22 @@ export function mockLegacyShadowContainerCliSpawner( getOutputFd: () => Stream.empty, }); } + if (isLocalDbInspect && opts.dbInspectImage !== undefined) { + const inspectJson = JSON.stringify([{ Config: { Image: opts.dbInspectImage } }]); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(7000 + spawned.length), + stdout: Stream.fromIterable([encoder.encode(inspectJson)]), + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + } let stdoutLines: ReadonlyArray = []; let stderrLines: ReadonlyArray = []; let exitCode = 0; diff --git a/packages/stack/package.json b/packages/stack/package.json index 68db806a45..3fe4966c4e 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -17,6 +17,7 @@ "default": "./src/managed-node.ts" }, "./managed-model": "./src/managed/model.ts", + "./versions": "./src/versions.ts", "./testing": "./src/testing.ts", "./daemon-bun": "./src/daemon-bun.ts" }, From efba3aa535ee6539e740e09541753c18a2096979 Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 25 Aug 2026 20:19:53 +0200 Subject: [PATCH 02/24] feat(cli): boot slim postgres images in legacy start and shadow flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With SUPABASE_USE_SLIM_IMAGES on and a resolved ghcr.io/supabase/cli/postgres ref, the main-db and shadow-db container specs switch to the slim image's contract: the image entrypoint is kept (entry.sh self-initializes and passes container argv through to postgres), config.toml db settings travel as -c argv pairs, and the CLI's bootstrap schema plus pgsodium root key are staged via the existing secret-file docker cp — the image's bundled migrate.sh runs /etc/postgresql.schema.sql as its first-boot postinit, giving exactly-once semantics without any CLI-side marker. PGSODIUM_KEY_FILE keeps the root key deterministic for the non-root (uid 65532) image user. start --from-backup refuses a slim resolved ref with a typed error until the restore flow learns the slim contract. Flag off (or a registry override landing on docker.io) stays byte-identical. Verified against a real slim db start: healthy container, settings applied, all three schema templates present exactly once across restarts, root key loaded, shadow spec exercised. Shadow flows additionally need a slim-services image fix (empty supautils GUCs block "create extension pg_net"). Co-Authored-By: Claude Fable 5 --- .../legacy/commands/db/start/SIDE_EFFECTS.md | 8 + .../db/start/start.integration.test.ts | 29 +++ .../src/legacy/commands/start/SIDE_EFFECTS.md | 4 + .../shared/db-bootstrap/postgres.service.ts | 228 +++++++++++++++--- .../postgres.service.unit.test.ts | 127 +++++++++- .../shared/db-bootstrap/start-database.ts | 36 +++ 6 files changed, 398 insertions(+), 34 deletions(-) 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 ac0e68469f..7e6e60fa91 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,14 @@ composition reuses too — see that command's `SIDE_EFFECTS.md`): `cron.launch_active_jobs = off` appended to `postgresql.conf` — applies regardless of `db.major_version`. The backup file itself is bind-mounted `:ro` at `/etc/backup.sql` (host path resolved against the CALLER's cwd when relative). + `SUPABASE_USE_SLIM_IMAGES` changes the container's shape (never its volume, published + port, healthcheck, or labels): the image's own entrypoint is kept instead of a `sh -c` + heredoc script, `[db.settings]` travels as trailing `-c key=value` argv rather than a + `postgresql.conf` append, and `/etc/postgresql.schema.sql` plus the pgsodium root key are + `docker cp`'d in before start (the slim image's bundled `migrate.sh` runs that schema file + once, at initdb, exactly like the docker.io image does). `--from-backup` combined with a + resolved slim image is refused here instead — the restore entrypoint has no slim + equivalent. 6. Wait for the container to become healthy (`db.health_timeout`, default `2m`). A timeout fails the command UNLESS `--from-backup` is set, in which case it is swallowed (a large restore can exceed the timeout) — the container-logs dump to stderr still happens 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 5f5244be1f..7758b43149 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", () => { @@ -652,6 +653,34 @@ describe("legacy db start", () => { }, ); + // The restore path is entirely a docker.io entrypoint feature, so the slim + // image is refused rather than silently starting an empty cluster. + it.live( + "--from-backup under SUPABASE_USE_SLIM_IMAGES is refused before any container is created", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(flags("/abs/host/backup.sql")).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toMatchObject({ + _tag: "LegacySlimImagesBackupUnsupportedError", + message: "--from-backup is not supported with SUPABASE_USE_SLIM_IMAGES", + }); + expect((error as { suggestion?: string }).suggestion).toContain( + "Unset SUPABASE_USE_SLIM_IMAGES", + ); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + it.live( '--from-backup against an existing volume fails with "backup volume already exists" and rolls back without creating a container', () => { diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index f36768a434..30d7cd53c3 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -103,6 +103,10 @@ Kong's `custom_nginx.template`, Vector's `vector.yaml`, and Postgres's own boots script (`postgresql.conf`-equivalent setup) are all rendered in memory and injected directly into each container's entrypoint (a `sh -c '... heredoc ...'` command) — never written to the host filesystem, since none of them carries secret content. +Exception: with `SUPABASE_USE_SLIM_IMAGES` enabled the Postgres container keeps the +slim image's own entrypoint — settings travel as `-c` argv and the bootstrap schema is +delivered via `docker cp` alongside the root key instead (see `db start`'s +SIDE_EFFECTS.md, which documents the slim container shape both commands share). 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 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 461744bf1f..a93316a490 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts @@ -22,6 +22,7 @@ import type { ProjectConfig } from "@supabase/config"; +import { isSlimImageRef, slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { localDbContainerId } from "../legacy-docker-ids.ts"; import { legacyToDockerPath } from "../legacy-docker-path.ts"; import { encodeToml } from "../legacy-go-output.encoders.ts"; @@ -50,6 +51,17 @@ const LEGACY_POSTGRES_PASSWORD = "postgres"; */ const LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH = "/etc/postgresql-custom/pgsodium_root.key"; +/** + * The post-migration hook path both image families read: `supabase/postgres`'s + * bundled `migrate.sh` execs `psql -v ON_ERROR_STOP=1 -U supabase_admin -f + * /etc/postgresql.schema.sql` as its last step when the file exists. The + * docker.io entrypoint heredocs it (see + * {@link legacyPostgresEntrypointScriptPg15}); the slim image's own entrypoint + * has no heredoc seam, so the slim path stages the same bytes at the same path + * via `secretFiles` instead — see {@link legacyPostgresSlimBootFields}. + */ +const LEGACY_POSTGRES_SCHEMA_SQL_PATH = "/etc/postgresql.schema.sql"; + /** 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; @@ -137,9 +149,7 @@ export interface LegacyPostgresStartServiceInput { export function legacyPostgresSettingsToPostgresConfig( settings: ProjectConfig["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 +157,36 @@ export function legacyPostgresSettingsToPostgresConfig( return `${LEGACY_POSTGRES_CONFIG_HEADER}${toml}`; } +/** + * The `[db.settings]` keys the user actually set — Go's TOML encoder skips nil + * pointers, so an unset field must never reach either renderer below. + */ +function legacyDefinedPostgresSettings( + settings: ProjectConfig["db"]["settings"], +): ReadonlyArray { + return Object.entries(settings ?? {}).filter( + (entry): entry is [string, string | number | boolean] => entry[1] !== undefined, + ); +} + +/** + * The same `[db.settings]` source as {@link legacyPostgresSettingsToPostgresConfig}, + * rendered as repeated `-c key=value` argv pairs instead of `postgresql.conf` + * lines. The slim image's entrypoint ends in `exec postgres -D $PGDATA "$@"`, so + * the container's trailing argv reaches the server verbatim — no conf-file append + * seam, and no shell quoting to get right, since each pair travels as its own + * argv element. Values are unquoted on purpose: the TOML renderer's single quotes + * are TOML syntax, whereas `postgres -c` takes the raw value. + */ +export function legacyPostgresSettingsToConfigArgs( + settings: ProjectConfig["db"]["settings"], +): ReadonlyArray { + return legacyDefinedPostgresSettings(settings).flatMap(([key, value]) => [ + "-c", + `${key}=${String(value)}`, + ]); +} + /** * Port of Go's `config.VersionCompare` (`apps/cli-go/pkg/config/config.go:885-899`) * — NOT a real semver comparator. A dotted version with more than 3 components @@ -282,7 +322,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 +373,7 @@ function legacyPostgresEntrypointScriptPg14(postgresConfig: string, args = ""): function legacyPostgresEntrypointScriptRestore(postgresConfig: string): string { return ( "\n" + - "cat <<'EOF' > /etc/postgresql.schema.sql && \\\n" + + `cat <<'EOF' > ${LEGACY_POSTGRES_SCHEMA_SQL_PATH} && \\\n` + "cat <<'EOF' > /docker-entrypoint-initdb.d/migrate.sh && \\\n" + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + "exec docker-entrypoint.sh postgres -D /etc/postgresql\n" + @@ -348,6 +388,86 @@ function legacyPostgresEntrypointScriptRestore(postgresConfig: string): string { ); } +/** + * Whether this container runs the `ghcr.io/supabase/cli` slim Postgres build rather than the + * docker.io one. The single gate shared by both spec builders and `start-database.ts`'s + * `--from-backup` refusal, so the guard can never drift from what the builders actually emit. + */ +export function legacyIsSlimPostgresImage(image: string): boolean { + return slimImagesEnabled() && isSlimImageRef(image); +} + +/** + * The bytes the docker.io entrypoint heredocs into + * {@link LEGACY_POSTGRES_SCHEMA_SQL_PATH} on PG >= 15 — schema.sql + webhook.sql + + * _supabase.sql, in that exact order, each terminated by the newline the heredoc + * body contributes (see {@link legacyPostgresEntrypointScriptPg15}). + */ +const LEGACY_POSTGRES_SLIM_SCHEMA_SQL = `${LEGACY_START_DB_SCHEMA_SQL}\n${LEGACY_START_DB_WEBHOOK_SQL}\n${LEGACY_START_DB_SUPABASE_SQL}\n`; + +/** + * The image-dependent half of a Postgres container spec: how the entrypoint is + * driven, which env it reads, and which files have to be staged before it runs. + * Shared by the real `db` container and the shadow, whose slim wiring differs + * only by the shadow's extra worker-cap argv. + */ +type LegacyPostgresBootFields = Pick< + LegacyStartContainerSpec, + "env" | "entrypoint" | "cmd" | "secretFiles" +>; + +/** + * Slim-image wiring. The slim build has none of the seams the docker.io + * entrypoint script relies on (no `docker-entrypoint.sh`, no writable + * `/etc/postgresql/postgresql.conf`, and a non-root `65532` runtime user), so + * instead of overriding the entrypoint this keeps the image's own + * `sh /usr/local/bin/entry.sh` and drives it through the two seams it does + * expose: + * + * - **Trailing argv** — `entry.sh` ends in `exec postgres -D $PGDATA "$@"`, so + * `[db.settings]` travels as `-c key=value` pairs + * ({@link legacyPostgresSettingsToConfigArgs}) rather than a conf-file append. + * Everything the docker.io script appends by hand (`listen_addresses`, `port`, + * `wal_level`, the `host all all all scram-sha-256` HBA rule) `entry.sh` + * already applies itself on first boot. + * - **{@link LEGACY_POSTGRES_SCHEMA_SQL_PATH}** — staged as a `secretFiles` + * entry so the bundled `migrate.sh` finds it and runs it exactly once, at + * initdb, as `supabase_admin` under `ON_ERROR_STOP=1`. That is the same + * post-migration hook the docker.io image runs, which is why this needs no + * first-boot bookkeeping of its own: a reused data directory skips + * `migrate.sh` entirely, so the non-idempotent schema SQL cannot re-apply. + * + * `PGSODIUM_KEY_FILE` points pgsodium/vault's bundled `getkey_script` at the + * root key this port already delivers via `docker cp`; without it the script + * generates a fresh random key under `$HOME`, so the cluster's encryption key + * would differ from `db.root_key` on every provision. `POSTGRES_HOST` and + * `POSTGRES_INITDB_ARGS` are deliberately absent: both configure the docker.io + * entrypoint only, and the slim bundle owns its own initdb flags. + */ +function legacyPostgresSlimBootFields(input: { + readonly settings: ProjectConfig["db"]["settings"]; + readonly rootKey: string; + readonly password: string; + readonly jwtSecret: string; + readonly jwtExpiry: number; + /** Extra `postgres` argv appended after the `[db.settings]` pairs — the shadow's own worker cap. */ + readonly extraArgs: ReadonlyArray; +}): LegacyPostgresBootFields { + return { + env: { + POSTGRES_PASSWORD: input.password, + JWT_SECRET: input.jwtSecret, + JWT_EXP: String(input.jwtExpiry), + PGSODIUM_KEY_FILE: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, + }, + cmd: [...legacyPostgresSettingsToConfigArgs(input.settings), ...input.extraArgs], + secretFiles: [ + { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: input.rootKey }, + { containerPath: LEGACY_POSTGRES_SCHEMA_SQL_PATH, content: LEGACY_POSTGRES_SLIM_SCHEMA_SQL }, + ], + }; +} + /** * Builds the {@link LegacyStartContainerSpec} for the Postgres container — shared by `supabase * start` (always {@link LegacyPostgresStartServiceInput.fromBackup} `undefined`) and `db start`'s @@ -362,6 +482,7 @@ export function legacyBuildPostgresStartContainerSpec( const postgresConfig = legacyPostgresSettingsToPostgresConfig(input.db.settings); const isPg14OrEarlier = input.db.major_version <= 14; const isRestore = input.fromBackup !== undefined; + const isSlim = legacyIsSlimPostgresImage(input.image); const env: Record = { // The constant `"postgres"` literal, matching Go, where `Db.Password` is @@ -383,12 +504,37 @@ export function legacyBuildPostgresStartContainerSpec( ? legacyPostgresEntrypointScriptPg14(postgresConfig) : legacyPostgresEntrypointScriptPg15(postgresConfig); + const bootFields: LegacyPostgresBootFields = isSlim + ? legacyPostgresSlimBootFields({ + settings: input.db.settings, + rootKey: rootKeyValue, + password: LEGACY_POSTGRES_PASSWORD, + jwtSecret: input.jwtSecret, + jwtExpiry: input.jwtExpiry, + extraArgs: [], + }) + : { + env, + entrypoint: "sh", + cmd: ["-c", script], + // The pgsodium root key heredoc/bind is present whenever the ACTUAL entrypoint in use + // embeds it: both `legacyPostgresEntrypointScriptPg15` and + // `legacyPostgresEntrypointScriptRestore` do (Go's `fromBackup` override always re-adds + // its own root-key heredoc, `start.go:147,155`, regardless of major version); only the + // PG<=14 script never references it. + ...(isPg14OrEarlier && !isRestore + ? {} + : { + secretFiles: [ + { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, + ], + }), + }; + return { image: input.image, containerName, - env, - entrypoint: "sh", - cmd: ["-c", script], + ...bootFields, binds: [ `${containerName}:/var/lib/postgresql/data`, // Go's `StartDatabase` (`start.go:163`) appends this bind ONLY on the `fromBackup` branch — @@ -401,17 +547,6 @@ 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"], @@ -429,11 +564,20 @@ export function legacyBuildPostgresStartContainerSpec( /** * Go's `NewContainerConfig("-c", "max_worker_processes=0")` (`CreateShadowDatabase`, * `apps/cli-go/internal/db/diff/diff.go:140`) — disables background workers in the - * shadow database. Not a docker flag: it is spliced into the entrypoint script's own - * `docker-entrypoint.sh postgres -D /etc/postgresql ` line, exactly like every - * other `args` value {@link legacyPostgresEntrypointScriptPg15}/`Pg14` accept. + * shadow database. Declared as argv because that is the shape the slim image + * consumes (trailing container argv, see {@link legacyPostgresSlimBootFields}); + * {@link LEGACY_SHADOW_ENTRYPOINT_ARGS} joins it back for the docker.io + * entrypoint script's own `` splice point. */ -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 @@ -495,6 +639,10 @@ export interface LegacyShadowPostgresContainerSpecInput { * it delivered before `docker start` — via `docker cp` straight into the container * (`container-lifecycle.ts`), same as every other container's `secretFiles`, never a * host temp file. + * - **Under the slim image the whole entrypoint/env/`secretFiles` group comes from + * {@link legacyPostgresSlimBootFields} instead**, with the shadow's worker cap appended to + * the container's trailing argv rather than spliced into a script — everything below stays + * as described. * - **Labels ARE still applied** (merged in by `legacyCreateContainer`, same as every * other container) so `supabase stop`'s label-filtered sweep catches an orphaned shadow * too — Go's `DockerStart` sets `CliProjectLabel`/`composeProjectLabel` unconditionally, @@ -508,6 +656,7 @@ export function legacyBuildShadowPostgresContainerSpec( const rootKeyValue = input.rootKey ?? LEGACY_POSTGRES_DEFAULT_ROOT_KEY; const postgresConfig = legacyPostgresSettingsToPostgresConfig(input.db.settings); const isPg14OrEarlier = input.db.major_version <= 14; + const isSlim = legacyIsSlimPostgresImage(input.image); const env: Record = { POSTGRES_PASSWORD: input.password, @@ -521,22 +670,35 @@ export function legacyBuildShadowPostgresContainerSpec( ? legacyPostgresEntrypointScriptPg14(postgresConfig, LEGACY_SHADOW_ENTRYPOINT_ARGS) : legacyPostgresEntrypointScriptPg15(postgresConfig, LEGACY_SHADOW_ENTRYPOINT_ARGS); + const bootFields: LegacyPostgresBootFields = isSlim + ? legacyPostgresSlimBootFields({ + settings: input.db.settings, + rootKey: rootKeyValue, + password: input.password, + jwtSecret: input.jwtSecret, + jwtExpiry: input.jwtExpiry, + extraArgs: LEGACY_SHADOW_ENTRYPOINT_ARGV, + }) + : { + env, + entrypoint: "sh", + cmd: ["-c", script], + ...(isPg14OrEarlier + ? {} + : { + secretFiles: [ + { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, + ], + }), + }; + return { image: input.image, containerName: "", - env, - entrypoint: "sh", - cmd: ["-c", script], + ...bootFields, binds: [], autoRemove: true, ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), - ...(isPg14OrEarlier - ? {} - : { - secretFiles: [ - { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, - ], - }), ports: [{ hostPort: String(input.shadowPort), containerPort: "5432" }], healthcheck: { test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], 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 16969daeb7..5cca8024b3 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 { ProjectConfig } from "@supabase/config"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { LEGACY_START_DB_RESTORE_SH } from "./templates/db-restore.sh.ts"; import { LEGACY_START_DB_SCHEMA_SQL } from "./templates/db-schema.sql.ts"; @@ -11,6 +11,7 @@ import { legacyBuildPostgresStartContainerSpec, legacyBuildShadowPostgresContainerSpec, legacyPostgresImageVersionTag, + legacyPostgresSettingsToConfigArgs, legacyPostgresSettingsToPostgresConfig, legacyPostgresVersionCompare, type LegacyPostgresStartServiceInput, @@ -19,6 +20,12 @@ import { const POSTGRES_CONFIG_HEADER = "\n# supabase [db.settings] configuration\n"; +const SLIM_POSTGRES_IMAGE = "ghcr.io/supabase/cli/postgres:17.6.1.165"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + function baseDb(overrides: Partial = {}): ProjectConfig["db"] { return { port: 54322, @@ -452,3 +459,121 @@ describe("legacyBuildShadowPostgresContainerSpec", () => { expect(spec.env?.["POSTGRES_PASSWORD"]).toBe("hunter2"); }); }); + +describe("legacyPostgresSettingsToConfigArgs", () => { + test("renders each set value as its own -c key=value pair, unquoted", () => { + expect( + legacyPostgresSettingsToConfigArgs({ + max_connections: 100, + shared_buffers: "128MB", + session_replication_role: "origin", + track_commit_timestamp: true, + }), + ).toEqual([ + "-c", + "max_connections=100", + "-c", + "shared_buffers=128MB", + "-c", + "session_replication_role=origin", + "-c", + "track_commit_timestamp=true", + ]); + }); + + test("emits nothing for empty settings, unlike the conf renderer's header-only output", () => { + expect(legacyPostgresSettingsToConfigArgs({})).toEqual([]); + expect(legacyPostgresSettingsToConfigArgs(undefined)).toEqual([]); + }); +}); + +describe("slim Postgres image spec", () => { + test("keeps the image's own entrypoint and passes [db.settings] as trailing -c argv instead of a heredoc script", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ + image: SLIM_POSTGRES_IMAGE, + db: baseDb({ settings: { max_connections: 120, effective_cache_size: "512MB" } }), + }), + ); + + expect(spec.entrypoint).toBeUndefined(); + expect(spec.cmd).toEqual(["-c", "max_connections=120", "-c", "effective_cache_size=512MB"]); + expect(spec.cmd?.join(" ")).not.toContain("docker-entrypoint.sh"); + }); + + test("stages the schema SQL the bundled migrate.sh hook runs, plus the pgsodium root key it is pointed at by env", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ image: SLIM_POSTGRES_IMAGE, rootKey: "custom-root-key" }), + ); + + expect(spec.env).toEqual({ + POSTGRES_PASSWORD: "postgres", + JWT_SECRET: "super-secret-jwt-token-with-at-least-32-characters-long", + JWT_EXP: "3600", + PGSODIUM_KEY_FILE: "/etc/postgresql-custom/pgsodium_root.key", + }); + expect(spec.secretFiles).toEqual([ + { containerPath: "/etc/postgresql-custom/pgsodium_root.key", content: "custom-root-key" }, + { + containerPath: "/etc/postgresql.schema.sql", + content: `${LEGACY_START_DB_SCHEMA_SQL}\n${LEGACY_START_DB_WEBHOOK_SQL}\n${LEGACY_START_DB_SUPABASE_SQL}\n`, + }, + ]); + }); + + test("leaves the volume bind, published port, healthcheck, and network wiring untouched", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ image: SLIM_POSTGRES_IMAGE, db: baseDb({ port: 12345 }) }), + ); + + expect(spec.binds).toEqual(["supabase_db_myproj:/var/lib/postgresql/data"]); + expect(spec.ports).toEqual([{ hostPort: "12345", containerPort: "5432" }]); + expect(spec.healthcheck).toEqual({ + test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }); + expect(spec.networkAliases).toEqual(["db", "db.supabase.internal"]); + expect(spec.restartPolicy).toBe("unless-stopped"); + }); + + test("appends the shadow's worker cap to the same trailing argv", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildShadowPostgresContainerSpec( + baseShadowInput({ + image: SLIM_POSTGRES_IMAGE, + db: { major_version: 17, settings: { max_connections: 120 } }, + password: "hunter2", + }), + ); + + expect(spec.entrypoint).toBeUndefined(); + expect(spec.cmd).toEqual(["-c", "max_connections=120", "-c", "max_worker_processes=0"]); + expect(spec.env?.["POSTGRES_PASSWORD"]).toBe("hunter2"); + expect(spec.autoRemove).toBe(true); + expect(spec.secretFiles?.map((file) => file.containerPath)).toEqual([ + "/etc/postgresql-custom/pgsodium_root.key", + "/etc/postgresql.schema.sql", + ]); + }); + + // The whole slim path hangs off the resolved ref, so a registry override that + // lands on docker.io keeps the heredoc entrypoint even with the flag set. + test("stays on the docker.io entrypoint when the flag is set but the resolved image is not a slim ref", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildPostgresStartContainerSpec(baseInput()); + expect(spec.entrypoint).toBe("sh"); + expect(spec.cmd?.[1]).toContain("exec docker-entrypoint.sh"); + }); + + test("stays on the docker.io entrypoint for a slim ref while the flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + const spec = legacyBuildPostgresStartContainerSpec(baseInput({ image: SLIM_POSTGRES_IMAGE })); + expect(spec.entrypoint).toBe("sh"); + expect(spec.cmd?.[1]).toContain("exec docker-entrypoint.sh"); + }); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts index 19bfb04569..6b7dc419cc 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -95,6 +95,7 @@ import { } from "./messages.ts"; import { legacyBuildPostgresStartContainerSpec, + legacyIsSlimPostgresImage, type LegacyPostgresStartServiceInput, } from "./postgres.service.ts"; @@ -121,11 +122,35 @@ export class LegacyStartBackupVolumeExistsError extends Data.TaggedError( } } +/** + * `--from-backup` reached a slim Postgres image. The docker.io restore path is entirely a + * property of that image's entrypoint — `docker-entrypoint.sh` running the restore script + * this port heredocs into `/docker-entrypoint-initdb.d/migrate.sh` against the + * `/etc/backup.sql` bind (`postgres.service.ts`'s restore entrypoint variant) — and the slim + * build ships neither seam, so a restore would silently start an empty cluster instead. + * Refused before any container is created. Exported only so the exhaustive actionability + * guard can inspect its declaration. + */ +export class LegacySlimImagesBackupUnsupportedError extends Data.TaggedError( + "LegacySlimImagesBackupUnsupportedError", +)<{ + readonly message: string; + readonly suggestion?: string; +}> { + // The remediation is to change what the caller passed in — the env flag, not the config file + // — and the error carries the concrete instruction, so this matches `provideFlags` rather + // than the suggestion-free `invalidInput`. + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** Every failure {@link legacyStartDatabase} itself can produce, independent of the caller's own `E`. */ export type LegacyStartDatabaseError = | LegacyNetworkCreateError | LegacyVolumeInspectError | LegacyStartBackupVolumeExistsError + | LegacySlimImagesBackupUnsupportedError | LegacyVolumeCreateError | LegacyContainerCreateError | LegacyContainerStartError @@ -229,6 +254,17 @@ export const legacyStartDatabase = ( const resolvedPostgresImage = yield* input.resolvePostgresImage; + // Gated on the resolved ref, not the env flag alone: a registry override can still land this + // run on a docker.io image, which restores fine. + if (fromBackup !== undefined && legacyIsSlimPostgresImage(resolvedPostgresImage)) { + return yield* Effect.fail( + new LegacySlimImagesBackupUnsupportedError({ + message: "--from-backup is not supported with SUPABASE_USE_SLIM_IMAGES", + suggestion: "Unset SUPABASE_USE_SLIM_IMAGES to restore from a backup.", + }), + ); + } + // 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 From 429bad2c79792d8ca51797361adbdd17327f15b3 Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 25 Aug 2026 20:45:46 +0200 Subject: [PATCH 03/24] fix(cli): guard slim postgres against docker.io-initialized volumes Reusing a named database volume initialized by the docker.io postgres image under SUPABASE_USE_SLIM_IMAGES crash-loops the slim image's non-root user against the 700-mode PGDATA until the health check times out. Before creating the container on an existing volume, a cheap docker-run probe now checks that the slim image's own user can read PG_VERSION and fails fast with remediation (stop --no-backup to reset, or unset the flag) when it cannot. Also documents the flag in the SIDE_EFFECTS of db pull, services, and the declarative schema commands, and records why the deno1 edge-runtime tag check intentionally also covers an explicit pin of that tag. Co-Authored-By: Claude Fable 5 --- .../legacy/commands/db/pull/SIDE_EFFECTS.md | 1 + .../declarative/generate/SIDE_EFFECTS.md | 31 +++++--- .../schema/declarative/sync/SIDE_EFFECTS.md | 26 ++++--- .../db/start/start.integration.test.ts | 71 +++++++++++++++++++ .../legacy/commands/services/SIDE_EFFECTS.md | 9 +-- .../db-bootstrap/container-lifecycle.ts | 58 +++++++++++++++ .../shared/db-bootstrap/start-database.ts | 66 +++++++++++++++-- .../src/shared/functions/functions.shared.ts | 6 +- 8 files changed, 236 insertions(+), 32 deletions(-) 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/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/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts index 50623602dc..eb1b1a285e 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 @@ -229,6 +229,26 @@ function runningCheckFailsRoute( }; } +/** Overrides the default route's answer to the slim-image reused-volume readability probe + * (`docker run --rm --entrypoint /usr/bin/sh -v :/probe -c "test -r + * /probe/PG_VERSION"`) so a test can force it readable/unreadable without a real container. */ +function slimVolumeProbeRoute( + base: (args: ReadonlyArray) => RouteResult, + readable: boolean, +): (args: ReadonlyArray) => RouteResult { + return (args) => { + if (args[0] === "run" && args.includes("--entrypoint")) { + return { exitCode: readable ? 0 : 1 }; + } + return base(args); + }; +} + +/** Whether the slim-image reused-volume readability probe (see {@link slimVolumeProbeRoute}) ran. */ +function slimVolumeProbeWasRun(spawned: ReadonlyArray): boolean { + return spawned.some((s) => s.args[0] === "run" && s.args.includes("--entrypoint")); +} + const alwaysReadyHttpClientLayer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -681,6 +701,57 @@ describe("legacy db start", () => { }, ); + // A reused volume's PGDATA ownership is a property of whichever image initialized it: a + // docker.io-initialized volume's `700`-mode dirs (owned by that image's `postgres` uid) block + // the slim image's non-root `65532` user, crash-looping until the health check times out with + // no useful message. `legacyIsVolumeReadableByImage` probes for this before any container is + // created. + it.live( + "SUPABASE_USE_SLIM_IMAGES against an existing volume unreadable by the slim image fails before any container is created", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const { layer, child } = setup({ route: slimVolumeProbeRoute(defaultRoute(), false) }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toMatchObject({ _tag: "LegacySlimImageVolumeUnreadableError" }); + expect((error as { message: string }).message).toContain("unreadable"); + const suggestion = (error as { suggestion?: string }).suggestion ?? ""; + expect(suggestion).toContain("supabase stop --no-backup"); + expect(suggestion).toContain("SUPABASE_USE_SLIM_IMAGES"); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + + it.live( + "SUPABASE_USE_SLIM_IMAGES against an existing volume readable by the slim image proceeds to create the container", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const { layer, child, out } = setup({ route: slimVolumeProbeRoute(defaultRoute(), true) }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(slimVolumeProbeWasRun(child.spawned)).toBe(true); + expect(createArgs(child.spawned)).not.toBeUndefined(); + expect(out.stderrText).toContain("Starting database from backup...\n"); + }); + }, + ); + + it.live( + "no SUPABASE_USE_SLIM_IMAGES: an existing volume never runs the slim-image readability probe", + () => { + const { layer, child } = setup(); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(slimVolumeProbeWasRun(child.spawned)).toBe(false); + }); + }, + ); + it.live( '--from-backup against an existing volume fails with "backup volume already exists" and rolls back without creating a container', () => { diff --git a/apps/cli/src/legacy/commands/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/shared/db-bootstrap/container-lifecycle.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts index 8a1dc49dd5..cf2519d332 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts @@ -489,6 +489,64 @@ export function legacyVolumeExists( ); } +/** + * Whether an EXISTING database volume is readable by the given (slim) Postgres image, via + * `docker run --rm --entrypoint /usr/bin/sh -v :/probe -c "test -r + * /probe/PG_VERSION"` — cheaper than a real bring-up attempt, and runs before any db container + * is created. `test -r` exits `0` when readable, `1` when not (e.g. a docker.io-initialized + * volume's `700`-mode PGDATA dirs, owned by that image's postgres uid, blocking the slim image's + * non-root `65532`). Any OTHER exit (spawn failure, or Docker's own `docker run` convention of + * `125`/`126`/`127` for a daemon/exec-level problem rather than the probed command's own exit) + * propagates as a genuine docker-run failure instead of being folded into the `1` case. + */ +export function legacyIsVolumeReadableByImage( + spawner: Spawner, + image: string, + name: string, +): Effect.Effect { + const fail = (message: string): LegacyContainerCreateError => + new LegacyContainerCreateError({ message, reason: "runtime" }); + return Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli( + spawner, + [ + "run", + "--rm", + "--entrypoint", + "/usr/bin/sh", + "-v", + `${name}:/probe`, + image, + "-c", + "test -r /probe/PG_VERSION", + ], + { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, + ).pipe( + Effect.mapError((cause) => + fail( + `failed to probe database volume readability: ${legacyDescribeContainerCliFailure(cause)}`, + ), + ), + ); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), legacyCollectText(child.stderr)], + { concurrency: "unbounded" }, + ).pipe(Effect.mapError(() => fail("failed to probe database volume readability"))); + if (exitCode === 0) return true; + if (exitCode === 1) return false; + const message = stderr.trim(); + return yield* Effect.fail( + fail( + message.length > 0 + ? `failed to probe database volume readability: ${message}` + : `failed to probe database volume readability: exit ${exitCode}`, + ), + ); + }), + ); +} + /** `docker container rm -f ` (or `docker rm -f`) failed. */ export class LegacyContainerRemoveError extends Data.TaggedError("LegacyContainerRemoveError")<{ readonly message: string; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts index 6b7dc419cc..60f7aaccc2 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -8,13 +8,20 @@ * Go's `StartDatabase` now only has one TS home to update. * * Exact Go call order: pre-create volume-existence probe (+ the `fromBackup`-on-an-existing-volume - * guard) -> image resolve + network ensure (Go's `DockerStart` resolves the image, THEN creates - * the network, both strictly ahead of container create — `docker.go:363-386` — so NEITHER one - * ever runs on a request the volume guard above already rejected) -> Postgres container - * create+start -> health wait (swallowed ONLY when `fromBackup` is set — "restoring a large - * backup may take longer than 2 minutes") -> the fresh-volume `SetupLocalDatabase`-equivalent - * pipeline (skipped IN FULL when `fromBackup` is set) -> `initCurrentBranch`, unconditionally (the - * LAST line of `StartDatabase`, reached on every path that doesn't already return/fail above). + * guard) -> image resolve (+ a TS-only slim-image reused-volume readability guard, see below) + + * network ensure (Go's `DockerStart` resolves the image, THEN creates the network, both strictly + * ahead of container create — `docker.go:363-386` — so NEITHER one ever runs on a request the + * volume guard above already rejected) -> Postgres container create+start -> health wait + * (swallowed ONLY when `fromBackup` is set — "restoring a large backup may take longer than 2 + * minutes") -> the fresh-volume `SetupLocalDatabase`-equivalent pipeline (skipped IN FULL when + * `fromBackup` is set) -> `initCurrentBranch`, unconditionally (the LAST line of `StartDatabase`, + * reached on every path that doesn't already return/fail above). + * + * The slim-image readability guard has no Go equivalent (`SUPABASE_USE_SLIM_IMAGES` is a TS-only + * feature): on an existing volume, once the image is resolved, a cheap `docker run` probe checks + * whether a slim (non-root `65532`) image can actually read PGDATA before any container is + * created — a docker.io-initialized volume's `700`-mode dirs otherwise crash-loop the slim + * process until the health check times out with no useful message. * * Deliberately has ZERO knowledge of `--ignore-health-check` — matching Go exactly: that flag is * `internal/start/start.go`'s `Run()`'s own concern, entirely OUTSIDE `StartDatabase` (Go's @@ -66,6 +73,7 @@ import type { LegacyDockerRun } from "../legacy-docker-run.service.ts"; import { legacyEnsureNetwork, legacyCreateContainer, + legacyIsVolumeReadableByImage, legacyVolumeExists, LEGACY_COMPOSE_PROJECT_LABEL, type LegacyContainerCreateError, @@ -145,12 +153,35 @@ export class LegacySlimImagesBackupUnsupportedError extends Data.TaggedError( } } +/** + * An existing db volume was initialized by a docker.io Postgres image (PGDATA owned by that + * image's `postgres` uid, `700`-mode dirs) and is being reused under the slim image, whose + * `65532` runtime user cannot read it — the slim process would otherwise crash-loop until the + * health check times out with no useful message. Detected by a pre-create readability probe + * (`legacyIsVolumeReadableByImage`), reached only when the resolved image is slim AND the volume + * already existed. Exported only so the exhaustive actionability guard can inspect its + * declaration. + */ +export class LegacySlimImageVolumeUnreadableError extends Data.TaggedError( + "LegacySlimImageVolumeUnreadableError", +)<{ + readonly message: string; + readonly suggestion?: string; +}> { + // Same shape as `LegacySlimImagesBackupUnsupportedError`: the fix is to change what the + // caller passed in — reset the volume or unset the env flag — not a suggestion-free default. + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** Every failure {@link legacyStartDatabase} itself can produce, independent of the caller's own `E`. */ export type LegacyStartDatabaseError = | LegacyNetworkCreateError | LegacyVolumeInspectError | LegacyStartBackupVolumeExistsError | LegacySlimImagesBackupUnsupportedError + | LegacySlimImageVolumeUnreadableError | LegacyVolumeCreateError | LegacyContainerCreateError | LegacyContainerStartError @@ -265,6 +296,27 @@ export const legacyStartDatabase = ( ); } + // A reused volume's PGDATA ownership is a property of whichever image initialized it, not + // of the image resolved for THIS run — a docker.io-initialized volume's `700`-mode dirs + // block the slim image's non-root user. Only reachable on an existing volume; a fresh one + // has no pre-existing ownership to conflict with. + if (!isFreshVolume && legacyIsSlimPostgresImage(resolvedPostgresImage)) { + const readable = yield* legacyIsVolumeReadableByImage( + spawner, + resolvedPostgresImage, + input.dbContainerId, + ); + if (!readable) { + return yield* Effect.fail( + new LegacySlimImageVolumeUnreadableError({ + message: + "the existing database volume was initialized by a non-slim postgres image and is unreadable by the slim image's user", + suggestion: `Run ${legacyAqua("supabase stop --no-backup")} to reset the local database, or unset SUPABASE_USE_SLIM_IMAGES.`, + }), + ); + } + } + // Go's `DockerStart` (`docker.go:363-386`): image resolve, THEN network create, both // strictly ahead of container create — hoisted here to run ONCE per `start` run instead of // once per container (Go's own repeated per-container call is a no-op after the first, see diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 32dc8e5c29..76f8e9cca1 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -55,7 +55,11 @@ export const DENO1_EDGE_RUNTIME_VERSION = "v1.68.4"; * 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. + * 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 { if (tag === DENO1_EDGE_RUNTIME_VERSION) { From 89b4e15a33308ec141e6e37084b89d6e41b47868 Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 25 Aug 2026 21:10:24 +0200 Subject: [PATCH 04/24] fix(cli): gate slim postgres readiness and one-shot dump jobs on the slim contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slim entrypoint runs a temporary server during first-boot init, so the slim specs' healthcheck now requires PID 1 to be the final postgres process before pg_isready counts (same gate the stack's docker path uses); docker.io keeps the plain pg_isready test byte-identically. The pg_dump one-shot runner (db dump, db pull, migration squash) now overrides the entrypoint to bash on slim refs — the slim entrypoint would otherwise initdb an empty cluster and hand the dump command to postgres. Also rewords a settings-renderer comment off Go-authority framing. Co-Authored-By: Claude Fable 5 --- .../shared/db-bootstrap/postgres.service.ts | 37 ++++++++- .../postgres.service.unit.test.ts | 38 ++++++++- .../src/legacy/shared/legacy-pg-dump.run.ts | 11 ++- .../shared/legacy-pg-dump.run.unit.test.ts | 83 +++++++++++++++++++ 4 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 apps/cli/src/legacy/shared/legacy-pg-dump.run.unit.test.ts 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 dc2fc89f5f..81ef182374 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts @@ -67,6 +67,34 @@ const LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS = 10; const LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS = 2; const LEGACY_POSTGRES_HEALTHCHECK_RETRIES = 3; +/** The docker.io image's healthcheck: `pg_isready` alone is a sufficient readiness probe. */ +const LEGACY_POSTGRES_HEALTHCHECK_TEST: ReadonlyArray = [ + "CMD", + "pg_isready", + "-U", + "postgres", + "-h", + "127.0.0.1", + "-p", + "5432", +]; + +/** + * The slim image's first boot runs initdb + bundled migrations against a TEMPORARY server + * before `entry.sh` execs the final `postgres` process, so PID 1 is still the entrypoint + * shell during that window and a bare `pg_isready` would pass against the temporary + * server. Gate on the final Postgres process the same way `packages/stack`'s own slim + * Docker healthcheck does (`postgresDockerHealthCheck`, + * `packages/stack/src/services/postgres.ts`). + */ +const LEGACY_POSTGRES_SLIM_HEALTHCHECK_TEST: ReadonlyArray = [ + "CMD", + "sh", + "-ec", + // Linux /proc/1/comm truncates `.postgres-wrapped` to 15 characters. + 'case "$(cat /proc/1/comm)" in postgres|.postgres-wrapp) pg_isready -U postgres -h 127.0.0.1 -p 5432 ;; *) exit 1 ;; esac', +]; + /** Go's `utils.DbAliases` (`apps/cli-go/internal/utils/config.go:36`). */ const LEGACY_POSTGRES_NETWORK_ALIASES: ReadonlyArray = ["db", "db.supabase.internal"]; @@ -158,8 +186,9 @@ export function legacyPostgresSettingsToPostgresConfig( } /** - * The `[db.settings]` keys the user actually set — Go's TOML encoder skips nil - * pointers, so an unset field must never reach either renderer below. + * 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"], @@ -549,7 +578,7 @@ export function legacyBuildPostgresStartContainerSpec( ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), ports: [{ hostPort: String(input.db.port), containerPort: "5432" }], healthcheck: { - test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], + test: isSlim ? LEGACY_POSTGRES_SLIM_HEALTHCHECK_TEST : LEGACY_POSTGRES_HEALTHCHECK_TEST, intervalSeconds: LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS, timeoutSeconds: LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS, retries: LEGACY_POSTGRES_HEALTHCHECK_RETRIES, @@ -701,7 +730,7 @@ export function legacyBuildShadowPostgresContainerSpec( ...(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: isSlim ? LEGACY_POSTGRES_SLIM_HEALTHCHECK_TEST : LEGACY_POSTGRES_HEALTHCHECK_TEST, intervalSeconds: LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS, timeoutSeconds: LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS, retries: LEGACY_POSTGRES_HEALTHCHECK_RETRIES, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts index f0987f492c..5c323a10e9 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 @@ -236,6 +236,21 @@ describe("legacyBuildPostgresStartContainerSpec", () => { }); }); + test("healthcheck stays the plain pg_isready probe on docker.io even with the slim flag set", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildPostgresStartContainerSpec(baseInput()); + expect(spec.healthcheck?.test).toEqual([ + "CMD", + "pg_isready", + "-U", + "postgres", + "-h", + "127.0.0.1", + "-p", + "5432", + ]); + }); + test("port binding maps the configured db.port to container port 5432", () => { const spec = legacyBuildPostgresStartContainerSpec(baseInput({ db: baseDb({ port: 12345 }) })); expect(spec.ports).toEqual([{ hostPort: "12345", containerPort: "5432" }]); @@ -532,7 +547,12 @@ describe("slim Postgres image spec", () => { expect(spec.binds).toEqual(["supabase_db_myproj:/var/lib/postgresql/data"]); expect(spec.ports).toEqual([{ hostPort: "12345", containerPort: "5432" }]); expect(spec.healthcheck).toEqual({ - test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], + test: [ + "CMD", + "sh", + "-ec", + 'case "$(cat /proc/1/comm)" in postgres|.postgres-wrapp) pg_isready -U postgres -h 127.0.0.1 -p 5432 ;; *) exit 1 ;; esac', + ], intervalSeconds: 10, timeoutSeconds: 2, retries: 3, @@ -541,6 +561,22 @@ describe("slim Postgres image spec", () => { expect(spec.restartPolicy).toBe("unless-stopped"); }); + test("healthcheck gates on the final postgres process, not the entrypoint shell PID 1 stays during first-boot init", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const dbSpec = legacyBuildPostgresStartContainerSpec(baseInput({ image: SLIM_POSTGRES_IMAGE })); + const shadowSpec = legacyBuildShadowPostgresContainerSpec( + baseShadowInput({ image: SLIM_POSTGRES_IMAGE }), + ); + const expected = [ + "CMD", + "sh", + "-ec", + 'case "$(cat /proc/1/comm)" in postgres|.postgres-wrapp) pg_isready -U postgres -h 127.0.0.1 -p 5432 ;; *) exit 1 ;; esac', + ]; + expect(dbSpec.healthcheck?.test).toEqual(expected); + expect(shadowSpec.healthcheck?.test).toEqual(expected); + }); + test("appends the shadow's worker cap to the same trailing argv", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = legacyBuildShadowPostgresContainerSpec( diff --git a/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts index 3194df9546..bfa8ea8785 100644 --- a/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts +++ b/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts @@ -3,6 +3,7 @@ import { Effect, Option } from "effect"; import { LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { legacyIsSlimPostgresImage } from "./db-bootstrap/postgres.service.ts"; import { legacyGetRegistryImageUrl } from "./legacy-docker-registry.ts"; import { LegacyDockerRun } from "./legacy-docker-run.service.ts"; @@ -64,10 +65,18 @@ export const legacyStreamPgDump = Effect.fnUntraced(function* (params: { : { _tag: "host" as const }; const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; + // The docker.io entrypoint execs non-`postgres` argv directly, so a plain `["bash", "-c", + // script, "--"]` cmd runs under the image's own entrypoint. The slim image's `entry.sh` + // instead always initdb's and execs `postgres` with whatever argv it is given, so this + // one-shot job needs its entrypoint overridden to the shell the cmd expects. + const isSlim = legacyIsSlimPostgresImage(params.image); + return yield* docker.runStream( { image: legacyGetRegistryImageUrl(params.image), - cmd: ["bash", "-c", params.script, "--"], + ...(isSlim + ? { entrypoint: Option.some("bash"), cmd: ["-c", params.script, "--"] } + : { cmd: ["bash", "-c", params.script, "--"] }), env: params.env, binds: [], workingDir: Option.none(), diff --git a/apps/cli/src/legacy/shared/legacy-pg-dump.run.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.run.unit.test.ts new file mode 100644 index 0000000000..2e7882e63e --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-pg-dump.run.unit.test.ts @@ -0,0 +1,83 @@ +import { Effect, Layer, Option } from "effect"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { LegacyDockerRun, type LegacyDockerRunOpts } from "./legacy-docker-run.service.ts"; +import { legacyStreamPgDump } from "./legacy-pg-dump.run.ts"; + +const DOCKER_IO_IMAGE = "supabase/postgres:17.4.1.030"; +const SLIM_IMAGE = "ghcr.io/supabase/cli/postgres:17.6.1.165"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function mockDockerRun() { + const calls: LegacyDockerRunOpts[] = []; + const layer = Layer.succeed(LegacyDockerRun, { + run: () => Effect.succeed(0), + runCapture: () => Effect.succeed({ exitCode: 0, stdout: new Uint8Array(0), stderr: "" }), + runStream: (opts) => + Effect.sync(() => { + calls.push(opts); + return { exitCode: 0, stderr: "" }; + }), + }); + return { + layer, + get lastOpts() { + return calls[calls.length - 1]; + }, + }; +} + +const runtimeInfoLayer = Layer.succeed(RuntimeInfo, { + cwd: "/work/project", + platform: "linux", + arch: "x64", + homeDir: "/home/user", + execPath: "/usr/bin/supabase", + pid: 1234, +}); + +function runStreamPgDump(image: string): LegacyDockerRunOpts { + const docker = mockDockerRun(); + const layer = Layer.mergeAll( + docker.layer, + runtimeInfoLayer, + Layer.succeed(LegacyNetworkIdFlag, Option.none()), + ); + Effect.runSync( + legacyStreamPgDump({ + image, + script: "pg_dump", + env: {}, + onStdout: () => Effect.void, + }).pipe(Effect.provide(layer)), + ); + const opts = docker.lastOpts; + if (opts === undefined) throw new Error("docker.runStream was never called"); + return opts; +} + +describe("legacyStreamPgDump entrypoint wiring", () => { + test("docker.io: keeps the image's own entrypoint, running bash under it", () => { + const opts = runStreamPgDump(DOCKER_IO_IMAGE); + expect(opts.entrypoint).toBeUndefined(); + expect(opts.cmd).toEqual(["bash", "-c", "pg_dump", "--"]); + }); + + test("SUPABASE_USE_SLIM_IMAGES unset: a ghcr.io-shaped image still keeps the docker.io cmd shape (flag-off byte-identity)", () => { + const opts = runStreamPgDump(SLIM_IMAGE); + expect(opts.entrypoint).toBeUndefined(); + expect(opts.cmd).toEqual(["bash", "-c", "pg_dump", "--"]); + }); + + test("slim image + flag on: overrides the entrypoint to bash, since entry.sh would otherwise initdb and exec the dump script as postgres argv", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const opts = runStreamPgDump(SLIM_IMAGE); + expect(Option.getOrUndefined(opts.entrypoint ?? Option.none())).toBe("bash"); + expect(opts.cmd).toEqual(["-c", "pg_dump", "--"]); + }); +}); From 235924d28ff7c169f0c7211c60ff5d3772c401e2 Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 25 Aug 2026 21:23:56 +0200 Subject: [PATCH 05/24] fix(cli): probe write access on reused slim volumes and drop _tag test coupling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Postgres must write postmaster.pid and WAL under PGDATA, so the reused-volume guard now also requires write access (test -w on the mount) — a read-only- accessible volume previously slipped past the guard into the same crash-loop it exists to prevent. The error and probe naming follow (Inaccessible / AccessibleToImage). The new integration tests narrow errors with instanceof on the exported classes instead of asserting the _tag field through casts. Co-Authored-By: Claude Fable 5 --- .../db/start/start.integration.test.ts | 46 +++++++++++-------- .../db-bootstrap/container-lifecycle.ts | 31 +++++++------ .../shared/db-bootstrap/start-database.ts | 27 +++++------ 3 files changed, 57 insertions(+), 47 deletions(-) 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 eb1b1a285e..bfcc2cf597 100644 --- a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -21,6 +21,10 @@ import { legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { + LegacySlimImagesBackupUnsupportedError, + LegacySlimImageVolumeInaccessibleError, +} from "../../../shared/db-bootstrap/start-database.ts"; import { LegacyDebugFlag, LegacyExperimentalFlag, @@ -229,22 +233,23 @@ function runningCheckFailsRoute( }; } -/** Overrides the default route's answer to the slim-image reused-volume readability probe +/** Overrides the default route's answer to the slim-image reused-volume access probe * (`docker run --rm --entrypoint /usr/bin/sh -v :/probe -c "test -r - * /probe/PG_VERSION"`) so a test can force it readable/unreadable without a real container. */ + * /probe/PG_VERSION && test -w /probe"`) so a test can force it accessible/inaccessible + * without a real container. */ function slimVolumeProbeRoute( base: (args: ReadonlyArray) => RouteResult, - readable: boolean, + accessible: boolean, ): (args: ReadonlyArray) => RouteResult { return (args) => { if (args[0] === "run" && args.includes("--entrypoint")) { - return { exitCode: readable ? 0 : 1 }; + return { exitCode: accessible ? 0 : 1 }; } return base(args); }; } -/** Whether the slim-image reused-volume readability probe (see {@link slimVolumeProbeRoute}) ran. */ +/** Whether the slim-image reused-volume access probe (see {@link slimVolumeProbeRoute}) ran. */ function slimVolumeProbeWasRun(spawned: ReadonlyArray): boolean { return spawned.some((s) => s.args[0] === "run" && s.args.includes("--entrypoint")); } @@ -688,13 +693,13 @@ describe("legacy db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const error = Cause.squash(exit.cause); - expect(error).toMatchObject({ - _tag: "LegacySlimImagesBackupUnsupportedError", - message: "--from-backup is not supported with SUPABASE_USE_SLIM_IMAGES", - }); - expect((error as { suggestion?: string }).suggestion).toContain( - "Unset SUPABASE_USE_SLIM_IMAGES", - ); + expect(error).toBeInstanceOf(LegacySlimImagesBackupUnsupportedError); + if (error instanceof LegacySlimImagesBackupUnsupportedError) { + expect(error.message).toBe( + "--from-backup is not supported with SUPABASE_USE_SLIM_IMAGES", + ); + expect(error.suggestion).toContain("Unset SUPABASE_USE_SLIM_IMAGES"); + } } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); }); @@ -704,10 +709,10 @@ describe("legacy db start", () => { // A reused volume's PGDATA ownership is a property of whichever image initialized it: a // docker.io-initialized volume's `700`-mode dirs (owned by that image's `postgres` uid) block // the slim image's non-root `65532` user, crash-looping until the health check times out with - // no useful message. `legacyIsVolumeReadableByImage` probes for this before any container is + // no useful message. `legacyIsVolumeAccessibleToImage` probes for this before any container is // created. it.live( - "SUPABASE_USE_SLIM_IMAGES against an existing volume unreadable by the slim image fails before any container is created", + "SUPABASE_USE_SLIM_IMAGES against an existing volume inaccessible to the slim image fails before any container is created", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const { layer, child } = setup({ route: slimVolumeProbeRoute(defaultRoute(), false) }); @@ -716,11 +721,12 @@ describe("legacy db start", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const error = Cause.squash(exit.cause); - expect(error).toMatchObject({ _tag: "LegacySlimImageVolumeUnreadableError" }); - expect((error as { message: string }).message).toContain("unreadable"); - const suggestion = (error as { suggestion?: string }).suggestion ?? ""; - expect(suggestion).toContain("supabase stop --no-backup"); - expect(suggestion).toContain("SUPABASE_USE_SLIM_IMAGES"); + expect(error).toBeInstanceOf(LegacySlimImageVolumeInaccessibleError); + if (error instanceof LegacySlimImageVolumeInaccessibleError) { + expect(error.message).toContain("not readable and writable"); + expect(error.suggestion).toContain("supabase stop --no-backup"); + expect(error.suggestion).toContain("SUPABASE_USE_SLIM_IMAGES"); + } } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); }); @@ -728,7 +734,7 @@ describe("legacy db start", () => { ); it.live( - "SUPABASE_USE_SLIM_IMAGES against an existing volume readable by the slim image proceeds to create the container", + "SUPABASE_USE_SLIM_IMAGES against an existing volume accessible to the slim image proceeds to create the container", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const { layer, child, out } = setup({ route: slimVolumeProbeRoute(defaultRoute(), true) }); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts index cf2519d332..885608cac8 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts @@ -490,16 +490,19 @@ export function legacyVolumeExists( } /** - * Whether an EXISTING database volume is readable by the given (slim) Postgres image, via - * `docker run --rm --entrypoint /usr/bin/sh -v :/probe -c "test -r - * /probe/PG_VERSION"` — cheaper than a real bring-up attempt, and runs before any db container - * is created. `test -r` exits `0` when readable, `1` when not (e.g. a docker.io-initialized - * volume's `700`-mode PGDATA dirs, owned by that image's postgres uid, blocking the slim image's - * non-root `65532`). Any OTHER exit (spawn failure, or Docker's own `docker run` convention of - * `125`/`126`/`127` for a daemon/exec-level problem rather than the probed command's own exit) - * propagates as a genuine docker-run failure instead of being folded into the `1` case. + * Whether an EXISTING database volume is accessible (readable AND writable) to the given (slim) + * Postgres image's own user, via `docker run --rm --entrypoint /usr/bin/sh -v :/probe + * -c "test -r /probe/PG_VERSION && test -w /probe"` — cheaper than a real bring-up + * attempt, and runs before any db container is created. Read alone is not enough: Postgres must + * write `postmaster.pid`/WAL under PGDATA, so a read-only-accessible volume would still + * crash-loop past this guard. The script exits `0` when accessible, `1` when not (e.g. a + * docker.io-initialized volume's `700`-mode PGDATA dirs, owned by that image's postgres uid, + * blocking the slim image's non-root `65532`). Any OTHER exit (spawn failure, or Docker's own + * `docker run` convention of `125`/`126`/`127` for a daemon/exec-level problem rather than the + * probed command's own exit) propagates as a genuine docker-run failure instead of being folded + * into the `1` case. */ -export function legacyIsVolumeReadableByImage( +export function legacyIsVolumeAccessibleToImage( spawner: Spawner, image: string, name: string, @@ -519,28 +522,28 @@ export function legacyIsVolumeReadableByImage( `${name}:/probe`, image, "-c", - "test -r /probe/PG_VERSION", + "test -r /probe/PG_VERSION && test -w /probe", ], { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, ).pipe( Effect.mapError((cause) => fail( - `failed to probe database volume readability: ${legacyDescribeContainerCliFailure(cause)}`, + `failed to probe database volume access: ${legacyDescribeContainerCliFailure(cause)}`, ), ), ); const [exitCode, stderr] = yield* Effect.all( [child.exitCode.pipe(Effect.map(Number)), legacyCollectText(child.stderr)], { concurrency: "unbounded" }, - ).pipe(Effect.mapError(() => fail("failed to probe database volume readability"))); + ).pipe(Effect.mapError(() => fail("failed to probe database volume access"))); if (exitCode === 0) return true; if (exitCode === 1) return false; const message = stderr.trim(); return yield* Effect.fail( fail( message.length > 0 - ? `failed to probe database volume readability: ${message}` - : `failed to probe database volume readability: exit ${exitCode}`, + ? `failed to probe database volume access: ${message}` + : `failed to probe database volume access: exit ${exitCode}`, ), ); }), 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 60f7aaccc2..8f166320e2 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -8,7 +8,7 @@ * Go's `StartDatabase` now only has one TS home to update. * * Exact Go call order: pre-create volume-existence probe (+ the `fromBackup`-on-an-existing-volume - * guard) -> image resolve (+ a TS-only slim-image reused-volume readability guard, see below) + + * guard) -> image resolve (+ a TS-only slim-image reused-volume access guard, see below) + * network ensure (Go's `DockerStart` resolves the image, THEN creates the network, both strictly * ahead of container create — `docker.go:363-386` — so NEITHER one ever runs on a request the * volume guard above already rejected) -> Postgres container create+start -> health wait @@ -17,9 +17,9 @@ * `fromBackup` is set) -> `initCurrentBranch`, unconditionally (the LAST line of `StartDatabase`, * reached on every path that doesn't already return/fail above). * - * The slim-image readability guard has no Go equivalent (`SUPABASE_USE_SLIM_IMAGES` is a TS-only + * The slim-image volume-access guard has no Go equivalent (`SUPABASE_USE_SLIM_IMAGES` is a TS-only * feature): on an existing volume, once the image is resolved, a cheap `docker run` probe checks - * whether a slim (non-root `65532`) image can actually read PGDATA before any container is + * whether a slim (non-root `65532`) image can actually read and write PGDATA before any container is * created — a docker.io-initialized volume's `700`-mode dirs otherwise crash-loop the slim * process until the health check times out with no useful message. * @@ -73,7 +73,7 @@ import type { LegacyDockerRun } from "../legacy-docker-run.service.ts"; import { legacyEnsureNetwork, legacyCreateContainer, - legacyIsVolumeReadableByImage, + legacyIsVolumeAccessibleToImage, legacyVolumeExists, LEGACY_COMPOSE_PROJECT_LABEL, type LegacyContainerCreateError, @@ -157,13 +157,14 @@ export class LegacySlimImagesBackupUnsupportedError extends Data.TaggedError( * An existing db volume was initialized by a docker.io Postgres image (PGDATA owned by that * image's `postgres` uid, `700`-mode dirs) and is being reused under the slim image, whose * `65532` runtime user cannot read it — the slim process would otherwise crash-loop until the - * health check times out with no useful message. Detected by a pre-create readability probe - * (`legacyIsVolumeReadableByImage`), reached only when the resolved image is slim AND the volume + * health check times out with no useful message. Detected by a pre-create access probe (read + write — + * Postgres must write under PGDATA) + * (`legacyIsVolumeAccessibleToImage`), reached only when the resolved image is slim AND the volume * already existed. Exported only so the exhaustive actionability guard can inspect its * declaration. */ -export class LegacySlimImageVolumeUnreadableError extends Data.TaggedError( - "LegacySlimImageVolumeUnreadableError", +export class LegacySlimImageVolumeInaccessibleError extends Data.TaggedError( + "LegacySlimImageVolumeInaccessibleError", )<{ readonly message: string; readonly suggestion?: string; @@ -181,7 +182,7 @@ export type LegacyStartDatabaseError = | LegacyVolumeInspectError | LegacyStartBackupVolumeExistsError | LegacySlimImagesBackupUnsupportedError - | LegacySlimImageVolumeUnreadableError + | LegacySlimImageVolumeInaccessibleError | LegacyVolumeCreateError | LegacyContainerCreateError | LegacyContainerStartError @@ -301,16 +302,16 @@ export const legacyStartDatabase = ( // block the slim image's non-root user. Only reachable on an existing volume; a fresh one // has no pre-existing ownership to conflict with. if (!isFreshVolume && legacyIsSlimPostgresImage(resolvedPostgresImage)) { - const readable = yield* legacyIsVolumeReadableByImage( + const accessible = yield* legacyIsVolumeAccessibleToImage( spawner, resolvedPostgresImage, input.dbContainerId, ); - if (!readable) { + if (!accessible) { return yield* Effect.fail( - new LegacySlimImageVolumeUnreadableError({ + new LegacySlimImageVolumeInaccessibleError({ message: - "the existing database volume was initialized by a non-slim postgres image and is unreadable by the slim image's user", + "the existing database volume was initialized by a non-slim postgres image and is not readable and writable by the slim image's user", suggestion: `Run ${legacyAqua("supabase stop --no-backup")} to reset the local database, or unset SUPABASE_USE_SLIM_IMAGES.`, }), ); From c030668ec95c5fdbcabffa9f3fed40be9cdbcff3 Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 25 Aug 2026 21:35:14 +0200 Subject: [PATCH 06/24] fix(cli): keep a deno1-tag edge-runtime pin on docker.io in the legacy resolver The legacy resolver and the functions-path edgeRuntimeImage read the same .temp/edge-runtime-version pin but disagreed under the slim flag: a stale v1.68.4 pin (left by an earlier deno_version = 1 run) tag-swapped onto the slim base yields an unpullable ref, since no slim build of that tag exists. Both resolvers now apply the same docker.io exception from one shared constant. Co-Authored-By: Claude Fable 5 --- .../shared/legacy-edge-runtime-image.ts | 15 +++++-- .../legacy-edge-runtime-image.unit.test.ts | 39 ++++++++++++++++++- 2 files changed, 50 insertions(+), 4 deletions(-) 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 25a2cd7401..1b3ce48062 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts @@ -1,4 +1,5 @@ import { Effect, type FileSystem, type Path } from "effect"; +import { DENO1_EDGE_RUNTIME_VERSION } from "../../shared/functions/functions.shared.ts"; import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; /** @@ -16,8 +17,10 @@ import { dockerfileServiceImage } from "../../shared/services/dockerfile-images. // 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. -const LEGACY_EDGE_RUNTIME_DENO1_IMAGE = "supabase/edge-runtime:v1.68.4"; +// 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 { @@ -45,7 +48,13 @@ export const legacyResolveEdgeRuntimeImage = Effect.fnUntraced(function* ( Effect.orElseSucceed(() => ""), ); if (pinned.length > 0) { - image = replaceImageTag(legacyEdgeRuntimeImage(), pinned); + // A pin of the deno1 tag (e.g. left in .temp by an earlier deno_version = 1 + // run) resolves docker.io whatever selected it: no slim build of that tag + // exists, so tag-swapping it onto a slim base would yield an unpullable ref. + image = + pinned === DENO1_EDGE_RUNTIME_VERSION + ? LEGACY_EDGE_RUNTIME_DENO1_IMAGE + : replaceImageTag(legacyEdgeRuntimeImage(), pinned); } if (denoVersion === 1) { image = LEGACY_EDGE_RUNTIME_DENO1_IMAGE; 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..4fa3e13b21 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,8 +2,9 @@ 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"; @@ -53,4 +54,40 @@ describe("legacyResolveEdgeRuntimeImage", () => { ), ); }); + + describe("with the slim-images flag on", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.effect("resolves a normal pin onto the slim base", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); + mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(dir, "supabase", ".temp", "edge-runtime-version"), "v9.9.9\n"); + return resolve(dir, 2).pipe( + Effect.tap((image) => + Effect.sync(() => { + expect(image).toBe("ghcr.io/supabase/cli/edge-runtime:v9.9.9"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("keeps 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 }); + }), + ), + ); + }); + }); }); From 3ab10e17996eac993f1f353e87cf0b0c64359ca9 Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 26 Aug 2026 04:16:05 +0000 Subject: [PATCH 07/24] fix(cli): adapt start runtime contracts for slim distroless images Docker CLI healthchecks are always CMD-SHELL, so distroless slim auth/storage/studio/pg-meta cannot probe via wget/node. Slim realtime one-shot jobs must override entry.sh, auth migrate argv drops the binary name, storage skips migrate-call.js, and vector/edge-runtime keep the image entrypoint instead of a shell wrapper. Co-authored-by: Andrew Valleteau --- .../src/legacy/commands/start/SIDE_EFFECTS.md | 7 ++ .../commands/start/services/gotrue.service.ts | 34 +++++--- .../services/gotrue.service.unit.test.ts | 29 ++++++- .../start/services/logflare.service.ts | 40 +++++++--- .../services/logflare.service.unit.test.ts | 25 +++++- .../start/services/pg-meta.service.ts | 25 +++--- .../services/pg-meta.service.unit.test.ts | 21 ++++- .../start/services/realtime.service.ts | 44 +++++----- .../services/realtime.service.unit.test.ts | 24 +++++- .../start/services/storage.service.ts | 39 +++++---- .../services/storage.service.unit.test.ts | 15 +++- .../commands/start/services/studio.service.ts | 25 +++--- .../services/studio.service.unit.test.ts | 15 +++- .../commands/start/services/vector.service.ts | 47 +++++++---- .../services/vector.service.unit.test.ts | 29 +++++++ .../legacy/shared/db-bootstrap/db-setup.ts | 80 +++++++++++-------- .../shared/db-bootstrap/db-setup.unit.test.ts | 42 +++++++++- .../shared/db-bootstrap/slim-runtime.ts | 47 +++++++++++ .../db-bootstrap/slim-runtime.unit.test.ts | 39 +++++++++ apps/cli/src/shared/functions/serve.ts | 17 +++- apps/cli/src/shared/services/slim-images.ts | 9 +++ .../shared/services/slim-images.unit.test.ts | 20 ++++- 22 files changed, 541 insertions(+), 132 deletions(-) create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.unit.test.ts diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 1ed9e73f0c..eb4abba2c9 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -107,6 +107,13 @@ Exception: with `SUPABASE_USE_SLIM_IMAGES` enabled the Postgres container keeps slim image's own entrypoint — settings travel as `-c` argv and the bootstrap schema is delivered via `docker cp` alongside the root key instead (see `db start`'s SIDE_EFFECTS.md, which documents the slim container shape both commands share). +The same flag also keeps Vector's image entrypoint (`vector --config /etc/vector/vector.yaml`) +and copies `vector.yaml` via `secretFiles` instead of a `sh` heredoc, because the slim Vector +image has no shell-based entrypoint override. Distroless slim services with no `/bin/sh` +(auth, storage, studio, pg-meta) omit Docker healthchecks — `docker create --health-cmd` is +always `CMD-SHELL` — and `legacyCheckContainerReady` treats `Running` as ready. Realtime and +analytics keep a busybox `wget --spider` probe; slim Edge Runtime is started without +`--entrypoint sh` (the wrapped binary has no shell). 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 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..793817ec57 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,27 @@ describe("legacyBuildGotrueContainerSpec", () => { "postgresql://supabase_auth_admin:secret@supabase_db_proj:5432/postgres", ); }); + + test("omits the wget healthcheck on a slim distroless auth image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildGotrueContainerSpec({ + 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, + }); + expect(spec.healthcheck).toBeUndefined(); + }); + + test("keeps the wget healthcheck for a ghcr-shaped override while the flag is off", () => { + const spec = legacyBuildGotrueContainerSpec({ + 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, + }); + expect(spec.healthcheck?.test[1]).toBe("wget"); + }); }); 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..76a8a3e920 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.ts @@ -37,8 +37,9 @@ import type { CliConfig } from "@supabase/config"; import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; -import { ramInBytes } from "../../../shared/legacy-size-units.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { legacyUsesSlimRuntime } from "../../../shared/db-bootstrap/slim-runtime.ts"; +import { ramInBytes } from "../../../shared/legacy-size-units.ts"; import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; import { legacyStartInternalDbUrl, @@ -227,21 +228,27 @@ export function legacyBuildStorageContainerSpec( containerName, env, binds: [`${containerName}:${LEGACY_STORAGE_DOCKER_PATH}`], - healthcheck: { - // "For some reason, localhost resolves to IPv6 address on GitPod which breaks - // healthcheck." — IPv4 loopback pinned. - test: [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - "http://127.0.0.1:5000/status", - ], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - }, + // Distroless slim storage has no /bin/sh; Docker CLI healthchecks are always + // CMD-SHELL. Omitting makes `legacyCheckContainerReady` treat Running as ready. + ...(legacyUsesSlimRuntime(input.image) + ? {} + : { + healthcheck: { + // "For some reason, localhost resolves to IPv6 address on GitPod which breaks + // healthcheck." — IPv4 loopback pinned. + test: [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:5000/status", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + }), restartPolicy: "unless-stopped", networkId: input.networkId, // The Storage network alias. diff --git a/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts index 69d8456c98..cd7a15ac10 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { legacyAppendStorageVectorEnv, @@ -8,6 +8,10 @@ import { type LegacyStorageEnvInput, } from "./storage.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + const baseEnvInput: LegacyStorageEnvInput = { targetMigration: "", anonKey: "anon-key", @@ -253,4 +257,13 @@ describe("legacyBuildStorageContainerSpec", () => { "postgresql://postgres:postgres@supabase_db_proj:5432/postgres", ); }); + + test("omits the Docker healthcheck on a slim distroless storage image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildStorageContainerSpec({ + ...input, + image: "ghcr.io/supabase/cli/storage:v1.70.3", + }); + expect(spec.healthcheck).toBeUndefined(); + }); }); diff --git a/apps/cli/src/legacy/commands/start/services/studio.service.ts b/apps/cli/src/legacy/commands/start/services/studio.service.ts index 291b575cc0..15789dbb0d 100644 --- a/apps/cli/src/legacy/commands/start/services/studio.service.ts +++ b/apps/cli/src/legacy/commands/start/services/studio.service.ts @@ -24,6 +24,7 @@ import { join } from "node:path"; import { legacyToDockerPath } from "../../../shared/legacy-docker-path.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { legacyUsesSlimRuntime } from "../../../shared/db-bootstrap/slim-runtime.ts"; /** Container-internal port Studio listens on — hardcoded, never configurable. */ const STUDIO_CONTAINER_PORT = 3000; @@ -186,15 +187,21 @@ export function legacyBuildStudioContainerSpec( containerName: input.containerName, env: legacyBuildStudioEnv({ ...input.env, containerSnippetsPath }), binds, - healthcheck: { - test: [ - "CMD-SHELL", - `node --eval="fetch('http://127.0.0.1:${STUDIO_CONTAINER_PORT}/api/platform/profile').then((r) => {if (!r.ok) throw new Error(r.status)})"`, - ], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - }, + // Distroless slim studio has no /bin/sh; Docker CLI healthchecks are always + // CMD-SHELL. Omitting makes `legacyCheckContainerReady` treat Running as ready. + ...(legacyUsesSlimRuntime(input.image) + ? {} + : { + healthcheck: { + test: [ + "CMD-SHELL", + `node --eval="fetch('http://127.0.0.1:${STUDIO_CONTAINER_PORT}/api/platform/profile').then((r) => {if (!r.ok) throw new Error(r.status)})"`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + }), ports: [{ hostPort: String(input.port), containerPort: String(STUDIO_CONTAINER_PORT) }], restartPolicy: "unless-stopped", networkId: input.networkId, diff --git a/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts index 80728d823d..fe534cd300 100644 --- a/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { legacyBuildStudioContainerSpec, @@ -6,6 +6,10 @@ import { type LegacyBuildStudioEnvInput, } from "./studio.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + const baseEnvInput: LegacyBuildStudioEnvInput = { dbPassword: "postgres", workdir: "/project", @@ -169,4 +173,13 @@ describe("legacyBuildStudioContainerSpec", () => { expect(spec.binds).toEqual(["/project/supabase/snippets:/project/supabase/snippets:rw"]); }); + + test("omits the Docker healthcheck on a slim distroless studio image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildStudioContainerSpec({ + ...baseSpecInput, + image: "ghcr.io/supabase/cli/studio:2026.08.17-sha-0c1da8f", + }); + expect(spec.healthcheck).toBeUndefined(); + }); }); diff --git a/apps/cli/src/legacy/commands/start/services/vector.service.ts b/apps/cli/src/legacy/commands/start/services/vector.service.ts index d1813c2604..1606b56f27 100644 --- a/apps/cli/src/legacy/commands/start/services/vector.service.ts +++ b/apps/cli/src/legacy/commands/start/services/vector.service.ts @@ -35,6 +35,10 @@ import * as ChildProcess from "effect/unstable/process/ChildProcess"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { + legacySlimBusyboxWgetHealthcheck, + legacyUsesSlimRuntime, +} from "../../../shared/db-bootstrap/slim-runtime.ts"; import { legacyRenderStartVectorYaml } from "../lib/template-render.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -344,18 +348,33 @@ export function legacyBuildVectorContainerSpec( dbId: input.dbId, }); - return { - image: input.image, - containerName: input.containerName, - env: input.dockerSocketPlan.env, - entrypoint: "sh", - cmd: ["-c", legacyBuildVectorEntrypointScript(vectorYaml, input.logflareId)], - binds: input.dockerSocketPlan.binds, - healthcheck: LEGACY_VECTOR_HEALTHCHECK, - restartPolicy: "unless-stopped", - securityOpt: input.dockerSocketPlan.securityOpt, - networkId: input.networkId, - networkAliases: LEGACY_VECTOR_NETWORK_ALIASES, - labels: {}, - }; + return legacyUsesSlimRuntime(input.image) + ? { + image: input.image, + containerName: input.containerName, + env: input.dockerSocketPlan.env, + cmd: ["--config", "/etc/vector/vector.yaml"], + secretFiles: [{ containerPath: "/etc/vector/vector.yaml", content: vectorYaml }], + binds: input.dockerSocketPlan.binds, + healthcheck: legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:9001/health"), + restartPolicy: "unless-stopped", + securityOpt: input.dockerSocketPlan.securityOpt, + networkId: input.networkId, + networkAliases: LEGACY_VECTOR_NETWORK_ALIASES, + labels: {}, + } + : { + image: input.image, + containerName: input.containerName, + env: input.dockerSocketPlan.env, + entrypoint: "sh", + cmd: ["-c", legacyBuildVectorEntrypointScript(vectorYaml, input.logflareId)], + binds: input.dockerSocketPlan.binds, + healthcheck: LEGACY_VECTOR_HEALTHCHECK, + restartPolicy: "unless-stopped", + securityOpt: input.dockerSocketPlan.securityOpt, + networkId: input.networkId, + networkAliases: LEGACY_VECTOR_NETWORK_ALIASES, + labels: {}, + }; } diff --git a/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts index 24762e5f5b..d3669438b7 100644 --- a/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, test } from "@effect/vitest"; +import { afterEach, vi } from "vitest"; import { Deferred, Effect, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -15,6 +16,10 @@ import { type LegacyVectorDockerSocketPlan, } from "./vector.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + /** Matches the standing `mockSpawner` shape in `image-prepull.unit.test.ts`. */ function mockSpawner( handler: (args: ReadonlyArray) => { exitCode: number; stdout?: string; stderr?: string }, @@ -295,6 +300,30 @@ describe("legacyBuildVectorContainerSpec", () => { expect(script).toContain('"supabase_vector_proj"'); expect(script).toContain('.appname == "supabase_kong_proj"'); }); + + test("delivers vector.yaml via secretFiles and uses busybox wget on a slim image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildVectorContainerSpec({ + ...base, + image: "ghcr.io/supabase/cli/vector:0.53.0", + }); + expect(spec.entrypoint).toBeUndefined(); + expect(spec.cmd).toEqual(["--config", "/etc/vector/vector.yaml"]); + expect(spec.secretFiles).toEqual([ + { + containerPath: "/etc/vector/vector.yaml", + content: expect.stringContaining('"supabase_vector_proj"'), + }, + ]); + expect(spec.healthcheck?.test).toEqual([ + "CMD", + "/bin/busybox", + "wget", + "-q", + "--spider", + "http://127.0.0.1:9001/health", + ]); + }); }); describe("legacyResolveDockerDaemonHost", () => { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts index b415f0cbae..b1ee7852c2 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -160,6 +160,7 @@ import { } from "../legacy-vault.ts"; import { legacyEnsureImagesCached, type LegacyImagePrepullError } from "./image-prepull.ts"; import { legacyResolvePinnedImage } from "./pinned-image.ts"; +import { legacyUsesSlimRuntime } from "./slim-runtime.ts"; import { LEGACY_COMPOSE_PROJECT_LABEL } from "./container-lifecycle.ts"; import { LEGACY_REALTIME_TENANT_ID, legacyBuildRealtimeEnv } from "./realtime-env.ts"; import { LEGACY_START_DB_GLOBALS_SQL } from "./templates/db-globals.sql.ts"; @@ -669,6 +670,8 @@ const legacyRunStartMigrateJob = Effect.fnUntraced(function* ( readonly projectEnvValues: Readonly> | undefined; /** `--debug` — Go's `utils.GetDebugLogger()`, see this function's own doc comment. */ readonly debug: boolean; + /** `--entrypoint` override; omit to keep the image's own ENTRYPOINT. */ + readonly entrypoint?: Option.Option; }, ) { const docker = yield* LegacyDockerRun; @@ -699,6 +702,7 @@ const legacyRunStartMigrateJob = Effect.fnUntraced(function* ( // Already resolved, immediately above — `LegacyDockerRun.runCapture`'s own ambient-only // resolver must not re-resolve it (it doesn't see `opts.projectEnvValues` at all). skipImageResolve: true, + ...(opts.entrypoint === undefined ? {} : { entrypoint: opts.entrypoint }), }; // `runStream` (not `runCapture`) so stdout is actually discarded chunk-by-chunk as it // arrives, matching Go's `io.Discard` writer for this job (`start.go:352`, and this @@ -802,6 +806,12 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( const dbPassword = legacyStartInternalDbPassword(input.dbUrl); if (input.config.realtime.enabled) { + // Slim realtime's ENTRYPOINT is tini + /app/entry.sh, which migrates then + // `exec`s the image CMD (`/app/bin/server`) and would hang a one-shot job. + // Override the entrypoint so this stays the docker.io eval (create the + // `realtime-dev` tenant), matching Go's `initRealtimeJob`. + const slimRealtime = legacyUsesSlimRuntime(input.images.realtime); + const realtimeEval = `{:ok, _} = Application.ensure_all_started(:realtime)\n{:ok, _} = Realtime.Tenants.health_check("${LEGACY_REALTIME_TENANT_ID}")`; yield* legacyRunStartMigrateJob(spawner, { image: input.images.realtime, networkId: input.networkId, @@ -816,11 +826,8 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( jwtSecret: input.jwtSecret, jwks: input.jwks, }), - cmd: [ - "/app/bin/realtime", - "eval", - `{:ok, _} = Application.ensure_all_started(:realtime)\n{:ok, _} = Realtime.Tenants.health_check("${LEGACY_REALTIME_TENANT_ID}")`, - ], + cmd: slimRealtime ? ["eval", realtimeEval] : ["/app/bin/realtime", "eval", realtimeEval], + entrypoint: slimRealtime ? Option.some("/app/bin/realtime") : undefined, }); } if (input.config.storage.enabled) { @@ -837,34 +844,43 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( // fix already applied to `resolveDbHealthTimeoutSeconds` and the // long-running Storage container's own file-size-limit parsing // (`start.handler.ts`). - const storageEnv = yield* Effect.try({ - try: () => - legacyStartStorageMigrateEnv({ - targetMigration: input.storageTargetMigration, - anonKey: input.anonKey, - serviceRoleKey: input.serviceRoleKey, - jwtSecret: input.jwtSecret, - dbHost, - dbPassword, - fileSizeLimit: input.config.storage.file_size_limit, - }), - catch: (cause) => - new LegacyDbSetupError({ - message: `invalid config for storage: ${errMessage(cause)}`, - reason: "invalid_config", - }), - }); - yield* legacyRunStartMigrateJob(spawner, { - image: input.images.storage, - networkId: input.networkId, - projectId: input.projectId, - projectEnvValues: input.projectEnvValues, - debug: input.debug, - env: storageEnv, - cmd: ["node", "dist/scripts/migrate-call.js"], - }); + // Slim storage has no `dist/scripts/migrate-call.js` (the docker.io one-shot + // cmd). Tenant migrations run when the long-running server boots, and the slim + // postgres image already applies the bundled storage schema at initdb. + if (!legacyUsesSlimRuntime(input.images.storage)) { + const storageEnv = yield* Effect.try({ + try: () => + legacyStartStorageMigrateEnv({ + targetMigration: input.storageTargetMigration, + anonKey: input.anonKey, + serviceRoleKey: input.serviceRoleKey, + jwtSecret: input.jwtSecret, + dbHost, + dbPassword, + fileSizeLimit: input.config.storage.file_size_limit, + }), + catch: (cause) => + new LegacyDbSetupError({ + message: `invalid config for storage: ${errMessage(cause)}`, + reason: "invalid_config", + }), + }); + yield* legacyRunStartMigrateJob(spawner, { + image: input.images.storage, + networkId: input.networkId, + projectId: input.projectId, + projectEnvValues: input.projectEnvValues, + debug: input.debug, + env: storageEnv, + cmd: ["node", "dist/scripts/migrate-call.js"], + }); + } } if (input.config.auth.enabled) { + // Slim auth bakes `/usr/local/bin/auth` as ENTRYPOINT, so `["gotrue", "migrate"]` + // would become `auth gotrue migrate`. The docker.io image has an empty + // entrypoint and expects the binary name in argv. + const slimAuth = legacyUsesSlimRuntime(input.images.auth); yield* legacyRunStartMigrateJob(spawner, { image: input.images.auth, networkId: input.networkId, @@ -879,7 +895,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( dbHost, dbPassword, }), - cmd: ["gotrue", "migrate"], + cmd: slimAuth ? ["migrate"] : ["gotrue", "migrate"], }); } }); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts index 8bbeea2160..ec7ffd37a5 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,7 +5,8 @@ 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 { Deferred, Effect, FileSystem, Layer, Path, Schema, Sink, Stream } from "effect"; +import { afterEach, vi } from "vitest"; +import { Deferred, Effect, FileSystem, Layer, Option, Path, Schema, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; @@ -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: skips storage migrate-call, overrides realtime entrypoint, and passes migrate as auth argv", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run( + baseInput(workdir, session, { + majorVersion: 15, + images: { + realtime: "ghcr.io/supabase/cli/realtime:v2.129.3", + storage: "ghcr.io/supabase/cli/storage:v1.70.3", + auth: "ghcr.io/supabase/cli/auth:v2.196.0", + }, + }), + out, + docker, + ).pipe( + Effect.map(() => { + expect(docker.runs.map((job) => job.image)).toEqual([ + "ghcr.io/supabase/cli/realtime:v2.129.3", + "ghcr.io/supabase/cli/auth:v2.196.0", + ]); + expect(docker.runs[0]?.entrypoint).toEqual(Option.some("/app/bin/realtime")); + expect(docker.runs[0]?.cmd[0]).toBe("eval"); + expect(docker.runs[0]?.cmd[1]).toContain('Realtime.Tenants.health_check("realtime-dev")'); + expect(docker.runs[1]?.cmd).toEqual(["migrate"]); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + it.effect( "labels every one-shot job with the project's Docker labels, matching Go's DockerStart (review: Codex, PR #6022)", () => { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts new file mode 100644 index 0000000000..58ebea9218 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts @@ -0,0 +1,47 @@ +/** + * Slim-image runtime contracts that differ from docker.io. Spec builders switch + * on {@link usesSlimImageRuntime} so flag-off stays byte-identical even if a + * caller passes a ghcr-shaped override. + * + * Docker CLI `--health-cmd` is always stored as `CMD-SHELL` and executed with + * `/bin/sh -c` (`docker-create-args.ts`). Distroless images with no `/bin/sh` + * (auth, storage, studio, pg-meta, edge-runtime) therefore cannot carry a + * Docker healthcheck through this CLI — omit it and let + * `legacyCheckContainerReady` treat `Running` as ready, the same as PostgREST. + * Elixir/busybox images (realtime, analytics) and Vector do ship `/bin/sh` plus + * a wget applet, so they keep an exec-form probe that the CLI quotes into + * CMD-SHELL. + */ + +import { usesSlimImageRuntime } from "../../../shared/services/slim-images.ts"; + +/** {@link usesSlimImageRuntime} under the mandatory `legacy` export prefix. */ +export function legacyUsesSlimRuntime(image: string): boolean { + return usesSlimImageRuntime(image); +} + +export const LEGACY_SLIM_BUSYBOX = "/bin/busybox"; + +export function legacySlimBusyboxWgetHealthcheck( + url: string, + opts: { readonly header?: string; readonly startPeriodSeconds?: number } = {}, +): { + readonly test: ReadonlyArray; + readonly intervalSeconds: number; + readonly timeoutSeconds: number; + readonly retries: number; + readonly startPeriodSeconds?: number; +} { + const test = ["CMD", LEGACY_SLIM_BUSYBOX, "wget", "-q", "--spider"]; + if (opts.header !== undefined) { + test.push("--header", opts.header); + } + test.push(url); + return { + test, + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + ...(opts.startPeriodSeconds === undefined ? {} : { startPeriodSeconds: opts.startPeriodSeconds }), + }; +} diff --git a/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.unit.test.ts new file mode 100644 index 0000000000..3ba0844a3c --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.unit.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + LEGACY_SLIM_BUSYBOX, + legacySlimBusyboxWgetHealthcheck, + legacyUsesSlimRuntime, +} from "./slim-runtime.ts"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("legacyUsesSlimRuntime", () => { + it("requires the flag and a slim ref", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + expect(legacyUsesSlimRuntime("ghcr.io/supabase/cli/storage:v1.70.3")).toBe(true); + expect(legacyUsesSlimRuntime("supabase/storage-api:v1.70.3")).toBe(false); + }); +}); + +describe("legacySlimBusyboxWgetHealthcheck", () => { + it("invokes busybox wget --spider", () => { + const check = legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:4000/health", { + header: "Host:realtime-dev", + startPeriodSeconds: 10, + }); + expect(check.test).toEqual([ + "CMD", + LEGACY_SLIM_BUSYBOX, + "wget", + "-q", + "--spider", + "--header", + "Host:realtime-dev", + "http://127.0.0.1:4000/health", + ]); + expect(check.startPeriodSeconds).toBe(10); + }); +}); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index a4196ec7f7..1df289a806 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -76,6 +76,7 @@ import { } from "./functions-docker.ts"; import { loadFunctionsCliConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; import { edgeRuntimeImage, resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; +import { usesSlimImageRuntime } from "../services/slim-images.ts"; const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); const defaultCliConfig = decodeCliConfig({}); @@ -1767,6 +1768,14 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo ...buildFunctionsServeInspectArgs(input.inspectMode, input.inspectMain), ...(input.debug ? ["--verbose"] : []), ]; + const slimEdgeRuntime = usesSlimImageRuntime(input.image); + if (slimEdgeRuntime && dockerMultilineEnvScript !== undefined) { + return yield* Effect.fail( + new Error( + "SUPABASE_USE_SLIM_IMAGES cannot source multiline function secrets: the slim edge-runtime image has no shell. Unset the flag, or remove newline-containing values from the functions env file.", + ), + ); + } const serveMainTemplate = yield* Effect.promise(() => getLegacyFunctionsServeMainTemplate()); // Streamed in via `docker cp` between create and start: embedding the template in the // `sh -c` argv hits Windows ENAMETOOLONG (#5711), and a single-file host bind mounts as @@ -1804,11 +1813,11 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo ...(input.inspectMode === undefined ? [] : ["-p", `${input.config.edgeRuntimeInspectorPort}:${dockerRuntimeInspectorPort}`]), - "--entrypoint", - "sh", + ...(slimEdgeRuntime ? [] : ["--entrypoint", "sh"]), input.image, - "-c", - buildServeEntrypointCommand(runtimeCommand, dockerMultilineEnvScript?.scriptPath), + ...(slimEdgeRuntime + ? runtimeCommand.slice(1) + : ["-c", buildServeEntrypointCommand(runtimeCommand, dockerMultilineEnvScript?.scriptPath)]), ]; // The container must exist for `docker cp` to have a target, and must not be running diff --git a/apps/cli/src/shared/services/slim-images.ts b/apps/cli/src/shared/services/slim-images.ts index 809d982df1..fca55caed3 100644 --- a/apps/cli/src/shared/services/slim-images.ts +++ b/apps/cli/src/shared/services/slim-images.ts @@ -70,3 +70,12 @@ export function slimImageForAlias(alias: string, image: string): string { export function isSlimImageRef(image: string): boolean { return image.startsWith(SLIM_IMAGE_PREFIX); } + +/** + * True when the flag is on AND `image` is a slim ghcr ref. Spec builders and + * one-shot jobs use this so a ghcr-shaped override with the flag off stays on + * the docker.io contract (same gate as {@link legacyIsSlimPostgresImage}). + */ +export function usesSlimImageRuntime(image: string): boolean { + return slimImagesEnabled() && isSlimImageRef(image); +} diff --git a/apps/cli/src/shared/services/slim-images.unit.test.ts b/apps/cli/src/shared/services/slim-images.unit.test.ts index b48da0efac..81539ca5a1 100644 --- a/apps/cli/src/shared/services/slim-images.unit.test.ts +++ b/apps/cli/src/shared/services/slim-images.unit.test.ts @@ -1,7 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { dockerfileServiceImages } from "./dockerfile-images.ts"; -import { slimImageForAlias, slimImagesEnabled, toSlimImage } from "./slim-images.ts"; +import { + slimImageForAlias, + slimImagesEnabled, + toSlimImage, + usesSlimImageRuntime, +} from "./slim-images.ts"; afterEach(() => { vi.unstubAllEnvs(); @@ -115,3 +120,16 @@ describe("slimImageForAlias", () => { ); }); }); + +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); + }); +}); From 9e7b85488f4284a311247b046fcc081d6f78dea8 Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 26 Aug 2026 04:18:47 +0000 Subject: [PATCH 08/24] test(cli): isolate slim healthcheck flag gating from ambient env Co-authored-by: Andrew Valleteau --- .../services/gotrue.service.unit.test.ts | 23 ++++++------------- .../shared/db-bootstrap/db-setup.unit.test.ts | 4 +++- .../shared/db-bootstrap/slim-runtime.ts | 4 +++- apps/cli/src/shared/functions/serve.ts | 5 +++- 4 files changed, 17 insertions(+), 19 deletions(-) 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 793817ec57..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 @@ -713,26 +713,17 @@ describe("legacyBuildGotrueContainerSpec", () => { ); }); - test("omits the wget healthcheck on a slim distroless auth image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const spec = legacyBuildGotrueContainerSpec({ + 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, - }); - expect(spec.healthcheck).toBeUndefined(); - }); - - test("keeps the wget healthcheck for a ghcr-shaped override while the flag is off", () => { - const spec = legacyBuildGotrueContainerSpec({ - 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, - }); - expect(spec.healthcheck?.test[1]).toBe("wget"); + }; + 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/shared/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts index ec7ffd37a5..e6b4fd4019 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 @@ -395,7 +395,9 @@ describe("legacyStartSetupLocalDatabase", () => { ]); expect(docker.runs[0]?.entrypoint).toEqual(Option.some("/app/bin/realtime")); expect(docker.runs[0]?.cmd[0]).toBe("eval"); - expect(docker.runs[0]?.cmd[1]).toContain('Realtime.Tenants.health_check("realtime-dev")'); + expect(docker.runs[0]?.cmd[1]).toContain( + 'Realtime.Tenants.health_check("realtime-dev")', + ); expect(docker.runs[1]?.cmd).toEqual(["migrate"]); rmSync(workdir, { recursive: true, force: true }); }), diff --git a/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts index 58ebea9218..fdef2843d1 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts @@ -42,6 +42,8 @@ export function legacySlimBusyboxWgetHealthcheck( intervalSeconds: 10, timeoutSeconds: 2, retries: 3, - ...(opts.startPeriodSeconds === undefined ? {} : { startPeriodSeconds: opts.startPeriodSeconds }), + ...(opts.startPeriodSeconds === undefined + ? {} + : { startPeriodSeconds: opts.startPeriodSeconds }), }; } diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 1df289a806..9c52628381 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -1817,7 +1817,10 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo input.image, ...(slimEdgeRuntime ? runtimeCommand.slice(1) - : ["-c", buildServeEntrypointCommand(runtimeCommand, dockerMultilineEnvScript?.scriptPath)]), + : [ + "-c", + buildServeEntrypointCommand(runtimeCommand, dockerMultilineEnvScript?.scriptPath), + ]), ]; // The container must exist for `docker cp` to have a target, and must not be running From 9b00b955b80a32ef63cf1b87aa841a5c48e0f861 Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 26 Aug 2026 04:22:48 +0000 Subject: [PATCH 09/24] fix(cli): skip slim realtime one-shot migrate job Slim realtime already migrates and seeds from entry.sh (SEED_SELF_HOST=true). The docker.io eval health_check starts a second BEAM against Postgres and fails under a slow daemon. Co-authored-by: Andrew Valleteau --- .../legacy/shared/db-bootstrap/db-setup.ts | 58 ++++++++++--------- .../shared/db-bootstrap/db-setup.unit.test.ts | 12 +--- 2 files changed, 34 insertions(+), 36 deletions(-) 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 b1ee7852c2..c4c4f8204a 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -18,7 +18,10 @@ * run-to-completion container on the SAME Docker network as `db` — Go's * `DockerStart` defaults `NetworkMode` to `utils.NetId` when unset, * `docker.go:379-383`), each gated on its own service's `enabled` flag and none - * of which touch `conn` directly: + * of which touch `conn` directly. Slim images skip the Realtime and Storage + * jobs: those images migrate (and Realtime seeds) from their long-running + * entrypoint. Slim Auth still runs a one-shot `migrate` under the baked + * `auth` ENTRYPOINT. * - `initRealtimeJob` (`start.go:268-295`) — reuses * `./realtime-env.ts`'s `legacyBuildRealtimeEnv`, which builds * the byte-identical env-var literal Go's own `initRealtimeJob` embeds @@ -670,8 +673,6 @@ const legacyRunStartMigrateJob = Effect.fnUntraced(function* ( readonly projectEnvValues: Readonly> | undefined; /** `--debug` — Go's `utils.GetDebugLogger()`, see this function's own doc comment. */ readonly debug: boolean; - /** `--entrypoint` override; omit to keep the image's own ENTRYPOINT. */ - readonly entrypoint?: Option.Option; }, ) { const docker = yield* LegacyDockerRun; @@ -702,7 +703,6 @@ const legacyRunStartMigrateJob = Effect.fnUntraced(function* ( // Already resolved, immediately above — `LegacyDockerRun.runCapture`'s own ambient-only // resolver must not re-resolve it (it doesn't see `opts.projectEnvValues` at all). skipImageResolve: true, - ...(opts.entrypoint === undefined ? {} : { entrypoint: opts.entrypoint }), }; // `runStream` (not `runCapture`) so stdout is actually discarded chunk-by-chunk as it // arrives, matching Go's `io.Discard` writer for this job (`start.go:352`, and this @@ -806,29 +806,33 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( const dbPassword = legacyStartInternalDbPassword(input.dbUrl); if (input.config.realtime.enabled) { - // Slim realtime's ENTRYPOINT is tini + /app/entry.sh, which migrates then - // `exec`s the image CMD (`/app/bin/server`) and would hang a one-shot job. - // Override the entrypoint so this stays the docker.io eval (create the - // `realtime-dev` tenant), matching Go's `initRealtimeJob`. - const slimRealtime = legacyUsesSlimRuntime(input.images.realtime); - const realtimeEval = `{:ok, _} = Application.ensure_all_started(:realtime)\n{:ok, _} = Realtime.Tenants.health_check("${LEGACY_REALTIME_TENANT_ID}")`; - yield* legacyRunStartMigrateJob(spawner, { - image: input.images.realtime, - networkId: input.networkId, - projectId: input.projectId, - projectEnvValues: input.projectEnvValues, - debug: input.debug, - env: legacyBuildRealtimeEnv({ - ipVersion: input.config.realtime.ip_version, - maxHeaderLength: input.config.realtime.max_header_length, - dbHost, - dbPassword, - jwtSecret: input.jwtSecret, - jwks: input.jwks, - }), - cmd: slimRealtime ? ["eval", realtimeEval] : ["/app/bin/realtime", "eval", realtimeEval], - entrypoint: slimRealtime ? Option.some("/app/bin/realtime") : undefined, - }); + // Slim realtime's ENTRYPOINT (`tini` + `/app/entry.sh`) already runs + // `/app/bin/migrate`, seeds when `SEED_SELF_HOST=true` (set by + // `legacyBuildRealtimeEnv`), then execs the server. The docker.io one-shot + // `eval` health_check would otherwise start a second BEAM against the + // same DB (and hang if it inherited `entry.sh`). Skip it. + if (!legacyUsesSlimRuntime(input.images.realtime)) { + yield* legacyRunStartMigrateJob(spawner, { + image: input.images.realtime, + networkId: input.networkId, + projectId: input.projectId, + projectEnvValues: input.projectEnvValues, + debug: input.debug, + env: legacyBuildRealtimeEnv({ + ipVersion: input.config.realtime.ip_version, + maxHeaderLength: input.config.realtime.max_header_length, + dbHost, + dbPassword, + jwtSecret: input.jwtSecret, + jwks: input.jwks, + }), + cmd: [ + "/app/bin/realtime", + "eval", + `{:ok, _} = Application.ensure_all_started(:realtime)\n{:ok, _} = Realtime.Tenants.health_check("${LEGACY_REALTIME_TENANT_ID}")`, + ], + }); + } } if (input.config.storage.enabled) { // `legacyStartStorageMigrateEnv` parses `storage.file_size_limit` via 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 e6b4fd4019..d0b281e139 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts @@ -6,7 +6,7 @@ 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, Option, Path, Schema, Sink, Stream } from "effect"; +import { Deferred, Effect, FileSystem, Layer, Path, Schema, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; @@ -369,7 +369,7 @@ describe("legacyStartSetupLocalDatabase", () => { }); it.effect( - "slim refs: skips storage migrate-call, overrides realtime entrypoint, and passes migrate as auth argv", + "slim refs: skips realtime and storage one-shot jobs and passes migrate as auth argv", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const workdir = makeWorkdir(); @@ -390,15 +390,9 @@ describe("legacyStartSetupLocalDatabase", () => { ).pipe( Effect.map(() => { expect(docker.runs.map((job) => job.image)).toEqual([ - "ghcr.io/supabase/cli/realtime:v2.129.3", "ghcr.io/supabase/cli/auth:v2.196.0", ]); - expect(docker.runs[0]?.entrypoint).toEqual(Option.some("/app/bin/realtime")); - expect(docker.runs[0]?.cmd[0]).toBe("eval"); - expect(docker.runs[0]?.cmd[1]).toContain( - 'Realtime.Tenants.health_check("realtime-dev")', - ); - expect(docker.runs[1]?.cmd).toEqual(["migrate"]); + expect(docker.runs[0]?.cmd).toEqual(["migrate"]); rmSync(workdir, { recursive: true, force: true }); }), ); From 29b411ea3ce262caefefb28e68918b21dc91c719 Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 26 Aug 2026 04:35:05 +0000 Subject: [PATCH 10/24] fix(cli): run slim edge-runtime from /tmp and grant storage vector Slim edge-runtime runs as uid 65532 and cannot read /root, so copy the main-service template to /tmp. Slim postgres leaves the postgres role as a non-superuser, so grant SUPERUSER and create the vector extension before storage-api's vector-bucket migrations run. Co-authored-by: Andrew Valleteau --- .../commands/functions/serve/SIDE_EFFECTS.md | 2 +- .../src/legacy/commands/start/SIDE_EFFECTS.md | 14 +++-- .../edge-runtime.service.integration.test.ts | 45 +++++++++++++++- .../legacy/shared/db-bootstrap/db-setup.ts | 25 +++++++++ .../shared/db-bootstrap/db-setup.unit.test.ts | 53 +++++++++++++++++++ apps/cli/src/shared/functions/serve.ts | 11 ++-- 6 files changed, 141 insertions(+), 9 deletions(-) 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 56d05a941e..0b4d7c18f8 100644 --- a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md @@ -114,6 +114,6 @@ Long-running raw log / error events only; there is no terminal `result` event on - Before each container (re)start, resolves the edge-runtime image through the same registry-candidate pull-with-retry every native `functions` Docker path uses: `docker image inspect ` (ECR, then GHCR, then Docker Hub) to check the local cache, then `docker pull ` with 2 retries (4s/8s backoff) on a miss, after `assertLocalDbRunning` — resolving it earlier would hijack the down-daemon error message that DB-inspect step is responsible for producing. - Runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`, same one `start`/`stop`/`status` use) on every startup/restart, before `assertLocalDbRunning` — an invalid config now fails `serve` up front even for fields this command never otherwise reads (e.g. a bad `db.major_version` or malformed auth hook). - A container crash terminates the command with a non-zero exit; only a watched-file change restarts the container — a crashed container is never auto-restarted. -- The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. It is delivered into the created (not yet started) container as a `docker cp` stdin tar archive at `/root/index.ts` — never a single-file host bind mount, which materializes as an empty directory on daemons that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, podman machines) and breaks bring-up with edge-runtime's "failed to determine entrypoint" (supabase/cli#6254). Only this bootstrap template is daemon-independent: user function sources, import maps, static files, and the multiline-env script directory (present only when an env value contains a newline) still arrive by host bind mounts, so they require a daemon that can see the project directory. +- The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. It is delivered into the created (not yet started) container as a `docker cp` stdin tar archive at `/root/index.ts` (docker.io) or `/tmp/index.ts` with `--main-service=/tmp` on slim images (uid 65532 cannot read `/root`) — never a single-file host bind mount, which materializes as an empty directory on daemons that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, podman machines) and breaks bring-up with edge-runtime's "failed to determine entrypoint" (supabase/cli#6254). Only this bootstrap template is daemon-independent: user function sources, import maps, static files, and the multiline-env script directory (present only when an env value contains a newline) still arrive by host bind mounts, so they require a daemon that can see the project directory. - Existing local values declared under an import map's `scopes` are explicit read-only Docker mounts and may resolve outside the nearest Git root; each distinct out-of-root host path prints one `WARN` during bring-up, deduplicated across Functions sharing an import map. Such out-of-root mounts are excluded from the file-watch set per Function, so a scope target contributes no watch root of its own and cannot enlarge or destabilise the watcher; a path that another Function reaches through its ordinary binds is still watched. Other file-valued binds are watched through their immediate parent non-recursively, while directory binds remain recursive. Missing targets retain serve's existing skip behavior. - **Intentional divergence from Go — spec-strict import-map key matching (CLI-2179, ruled 2026-08-12):** bind mounts are computed by the functions import scanner (`walkImportPaths`/`substituteImportMapValue`, shared with `functions deploy` and `start`'s Edge Runtime bring-up), which matches import-map keys per the import-maps spec Deno/edge-runtime implement — exact match, or prefix match only for a `/`-suffixed key — instead of Go's any-key `strings.HasPrefix` (`pkg/function/deno.go:150-155`). Bind mounts may shrink vs the Go CLI for maps that relied on bare-key prefix matching; an unwalkable target (`ENOTDIR` — a value routed through a file) is skipped with a `WARN`. diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index eb4abba2c9..65856c3189 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -32,7 +32,9 @@ after Postgres's own health check passes, before "Starting containers..." prints before any other service starts. Opens a direct `LegacyDbConnection` session to the host-facing Postgres address (PG<=14: execs schema/globals/API-privileges SQL over that session; PG>=15: runs three one-shot `LegacyDockerRun` jobs instead, gated independently on -`realtime.enabled`/`storage.enabled`/`auth.enabled`). Also upserts `[db.vault]` secrets and +`realtime.enabled`/`storage.enabled`/`auth.enabled`; slim refs skip the Realtime and Storage +jobs and run Auth as `migrate`. Slim Storage then grants `postgres` SUPERUSER and creates +the `vector` extension so storage-api's vector-bucket migrations can run). 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 + @@ -113,7 +115,12 @@ image has no shell-based entrypoint override. Distroless slim services with no ` (auth, storage, studio, pg-meta) omit Docker healthchecks — `docker create --health-cmd` is always `CMD-SHELL` — and `legacyCheckContainerReady` treats `Running` as ready. Realtime and analytics keep a busybox `wget --spider` probe; slim Edge Runtime is started without -`--entrypoint sh` (the wrapped binary has no shell). +`--entrypoint sh` (the wrapped binary has no shell) and the main-service template is +copied to `/tmp/index.ts` (`--main-service=/tmp`) because the image runs as uid 65532 +and cannot read `/root`. After schema init, slim Storage also execs +`ALTER ROLE postgres WITH SUPERUSER` plus `CREATE EXTENSION vector` — slim postgres +leaves `postgres` as a non-superuser, and storage-api's vector-bucket migrations create +that extension as that role. Kong's `kong.yml`/TLS cert/TLS key, Postgres's `pgsodium_root.key`, and Supavisor's `pooler_tenant.exs` DO carry secret content (a service-role-key-derived bearer/query key, TLS private key material, and the DB password respectively). Since @@ -148,7 +155,8 @@ recreates its own subdirectory fresh on every call (self-healing), so a shrinking env set never leaves stale files behind. The bootstrap `index.ts` template carries no secret content and, as of supabase/cli#6254, never touches host disk at all: it is streamed via `docker cp` straight into the created (not yet started) Edge Runtime -container — a single-file host bind mount materializes as an empty directory on daemons +container at `/root/index.ts` (docker.io) or `/tmp/index.ts` (slim, uid 65532 cannot +read `/root`) — a single-file host bind mount materializes as an empty directory on daemons that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, podman machines), which broke `start` with edge-runtime's "failed to determine entrypoint". Only the bootstrap template is daemon-independent: user function 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..ab1fe4225f 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,45 @@ describe("legacyStartEdgeRuntimeContainer", () => { }), ); + it.effect( + "slim edge-runtime: copies the main service to /tmp (uid 65532 cannot read /root) and drops --entrypoint sh", + () => + Effect.gen(function* () { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const mock = mockDockerSpawner(); + const out = mockOutput(); + const input = { + ...baseInput(tempWorkdir.current), + image: "ghcr.io/supabase/cli/edge-runtime:v1.74.2", + }; + + yield* legacyStartEdgeRuntimeContainer(input).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const createArgs = mock.runCall!.args; + expect(createArgs).not.toContain("--entrypoint"); + expect(createArgs).toContain("start"); + expect(createArgs).toContain("--main-service=/tmp"); + expect(createArgs).not.toContain("--main-service=/root"); + + const cp = mock.calls.find((call) => call.args[0] === "cp"); + expect(cp?.args).toEqual(["cp", "-", "supabase_edge_runtime_proj:/"]); + const stdin = cp?.stdin; + expect(Stream.isStream(stdin)).toBe(true); + if (!Stream.isStream(stdin)) return yield* Effect.die("docker cp stdin was not a stream"); + const chunks = yield* Stream.runCollect(stdin); + expect(chunks).toHaveLength(1); + const archiveBytes = chunks[0]; + if (!(archiveBytes instanceof Uint8Array)) { + return yield* Effect.die("docker cp stdin did not contain archive bytes"); + } + const files = yield* Effect.promise(() => new Bun.Archive(archiveBytes).files()); + expect([...files.keys()]).toEqual(["tmp/index.ts"]); + }), + ); + it.effect( "surfaces docker's own stderr verbatim and never reaches cp/start when docker create fails", () => 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 c4c4f8204a..c9285d5ce4 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -34,6 +34,11 @@ * `STORAGE_S3_REGION`, no JWKS) — built locally, not reused. * - `initAuthJob` (`start.go:319-332`) — ditto, a minimal env distinct from * `gotrue.service.ts`'s full container builder. + * After `initSchema`, slim Storage execs + * {@link LEGACY_START_SLIM_STORAGE_VECTOR_SQL}: slim postgres leaves the + * `postgres` role as a non-superuser, and storage-api's vector-bucket + * migrations `CREATE EXTENSION vector` as that role. Docker.io postgres + * already grants SUPERUSER, so this is slim-only. * 2. **Database Webhooks activation** — installs `pg_net` when the user opted * into `experimental.webhooks`, unless the setup caller disables user extension * activation. Legacy callers may explicitly request the historical `pg_net` @@ -192,6 +197,16 @@ alter default privileges for role postgres in schema public revoke execute on functions from anon, authenticated, service_role; `; +/** + * Slim postgres leaves the `postgres` role as a non-superuser. Storage-api's + * vector-bucket migrations run as that role and `CREATE EXTENSION vector`, which + * docker.io postgres allows because `postgres` is a superuser there. + */ +export const LEGACY_START_SLIM_STORAGE_VECTOR_SQL = ` +ALTER ROLE postgres WITH SUPERUSER; +CREATE EXTENSION IF NOT EXISTS vector; +`; + /** * Exported for the shadow baseline cache's embedded-SQL digest (`shadow-cache.ts`), same as * {@link LEGACY_START_REVOKE_API_PRIVILEGES_SQL}: a webhooks-enabled baseline bakes this @@ -1096,6 +1111,16 @@ export const legacySetupDatabase = ( ); const requiresPg14WebhooksCleanup = input.majorVersion === 14; yield* legacyStartInitSchema(spawner, input, tmpDir); + if (input.config.storage.enabled && legacyUsesSlimRuntime(input.images.storage)) { + yield* legacyExecSqlConstant( + session, + fs, + path, + tmpDir, + "slim-storage-vector.sql", + LEGACY_START_SLIM_STORAGE_VECTOR_SQL, + ); + } if (requiresPg14WebhooksCleanup) { yield* legacyRemoveDatabaseWebhooks(session, fs, path, tmpDir); } 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 d0b281e139..03a4a0e045 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 @@ -399,6 +399,59 @@ describe("legacyStartSetupLocalDatabase", () => { }, ); + it.effect( + "slim storage: grants postgres SUPERUSER and creates the vector extension after initSchema", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const workdir = makeWorkdir(); + const { session, calls } = 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(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes("ALTER ROLE postgres WITH SUPERUSER"))).toBe( + true, + ); + expect( + execSql.some((sql) => sql.includes("CREATE EXTENSION IF NOT EXISTS vector")), + ).toBe(true); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + + it.effect("docker.io storage: does not grant postgres SUPERUSER for vector", () => { + const workdir = makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 15 }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes("ALTER ROLE postgres WITH SUPERUSER"))).toBe( + false, + ); + expect(execSql.some((sql) => sql.includes("CREATE EXTENSION IF NOT EXISTS vector"))).toBe( + false, + ); + 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/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 9c52628381..1b7d970dc0 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -109,7 +109,8 @@ const ignoredDirNames = new Set([ const dockerLogRetryDelay = Duration.millis(400); const dockerLogDiagnosticTailLength = 4_096; const defaultSupabaseEnv = "development"; -const serveMainContainerPath = "/root/index.ts"; +const slimServeMainDir = "/tmp"; +const dockerIoServeMainDir = "/root"; const shellVariableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/; let cachedLegacyFunctionsServeMainTemplate: string | undefined; const watchIgnoreGlobs = [ @@ -1759,16 +1760,18 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo }); const labels = dockerProjectLabels(projectId); + const slimEdgeRuntime = usesSlimImageRuntime(input.image); + const serveMainDir = slimEdgeRuntime ? slimServeMainDir : dockerIoServeMainDir; + const serveMainFile = `${serveMainDir}/index.ts`; const runtimeCommand = [ "edge-runtime", "start", - "--main-service=/root", + `--main-service=${serveMainDir}`, `--port=${dockerRuntimeServerPort}`, `--policy=${input.config.edgeRuntimePolicy}`, ...buildFunctionsServeInspectArgs(input.inspectMode, input.inspectMain), ...(input.debug ? ["--verbose"] : []), ]; - const slimEdgeRuntime = usesSlimImageRuntime(input.image); if (slimEdgeRuntime && dockerMultilineEnvScript !== undefined) { return yield* Effect.fail( new Error( @@ -1781,7 +1784,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); From e13d34b1e01bcf58017140676cacbecf58e7879c Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 26 Aug 2026 04:42:18 +0000 Subject: [PATCH 11/24] fix(cli): re-promote slim postgres as supabase_admin during initdb The postgres session cannot ALTER ROLE itself after the slim image demotes it. Append SUPERUSER plus CREATE EXTENSION vector to the migrate.sh postinit schema so supabase_admin applies it before the CLI connects. Co-authored-by: Andrew Valleteau --- .../legacy/commands/db/start/SIDE_EFFECTS.md | 4 +- .../src/legacy/commands/start/SIDE_EFFECTS.md | 11 ++-- .../legacy/shared/db-bootstrap/db-setup.ts | 25 --------- .../shared/db-bootstrap/db-setup.unit.test.ts | 53 ------------------- .../shared/db-bootstrap/postgres.service.ts | 22 ++++++-- .../postgres.service.unit.test.ts | 2 +- 6 files changed, 26 insertions(+), 91 deletions(-) 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 7e6e60fa91..0c6bb3d36d 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -36,7 +36,9 @@ composition reuses too — see that command's `SIDE_EFFECTS.md`): heredoc script, `[db.settings]` travels as trailing `-c key=value` argv rather than a `postgresql.conf` append, and `/etc/postgresql.schema.sql` plus the pgsodium root key are `docker cp`'d in before start (the slim image's bundled `migrate.sh` runs that schema file - once, at initdb, exactly like the docker.io image does). `--from-backup` combined with a + once, at initdb, exactly like the docker.io image does). Slim schema SQL also re-promotes + `postgres` to SUPERUSER and `CREATE EXTENSION vector` after the image's bundled demote + migration, so storage-api vector-bucket migrations can run as that role. `--from-backup` combined with a resolved slim image is refused here instead — the restore entrypoint has no slim equivalent. 6. Wait for the container to become healthy (`db.health_timeout`, default `2m`). A timeout diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 65856c3189..c0e4694710 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -33,8 +33,7 @@ before any other service starts. Opens a direct `LegacyDbConnection` session to 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`; slim refs skip the Realtime and Storage -jobs and run Auth as `migrate`. Slim Storage then grants `postgres` SUPERUSER and creates -the `vector` extension so storage-api's vector-bucket migrations can run). Also upserts `[db.vault]` secrets and +jobs and run Auth as `migrate`). Also upserts `[db.vault]` secrets and seeds `supabase/roles.sql`: the `Seeding globals from roles.sql...` stderr line always prints first, whether or not the file exists — a missing file is silently tolerated (no SQL runs), any other read/exec error still fails the run. Finally runs every pending migration + @@ -117,10 +116,10 @@ always `CMD-SHELL` — and `legacyCheckContainerReady` treats `Running` as ready analytics keep a busybox `wget --spider` probe; slim Edge Runtime is started without `--entrypoint sh` (the wrapped binary has no shell) and the main-service template is copied to `/tmp/index.ts` (`--main-service=/tmp`) because the image runs as uid 65532 -and cannot read `/root`. After schema init, slim Storage also execs -`ALTER ROLE postgres WITH SUPERUSER` plus `CREATE EXTENSION vector` — slim postgres -leaves `postgres` as a non-superuser, and storage-api's vector-bucket migrations create -that extension as that role. +and cannot read `/root`. Slim Postgres's `/etc/postgresql.schema.sql` postinit also +runs `ALTER ROLE postgres WITH SUPERUSER` plus `CREATE EXTENSION vector` as +`supabase_admin` after bundled migrations demote `postgres` — storage-api's +vector-bucket migrations create that extension as the `postgres` role. Kong's `kong.yml`/TLS cert/TLS key, Postgres's `pgsodium_root.key`, and Supavisor's `pooler_tenant.exs` DO carry secret content (a service-role-key-derived bearer/query key, TLS private key material, and the DB password respectively). Since 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 c9285d5ce4..c4c4f8204a 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -34,11 +34,6 @@ * `STORAGE_S3_REGION`, no JWKS) — built locally, not reused. * - `initAuthJob` (`start.go:319-332`) — ditto, a minimal env distinct from * `gotrue.service.ts`'s full container builder. - * After `initSchema`, slim Storage execs - * {@link LEGACY_START_SLIM_STORAGE_VECTOR_SQL}: slim postgres leaves the - * `postgres` role as a non-superuser, and storage-api's vector-bucket - * migrations `CREATE EXTENSION vector` as that role. Docker.io postgres - * already grants SUPERUSER, so this is slim-only. * 2. **Database Webhooks activation** — installs `pg_net` when the user opted * into `experimental.webhooks`, unless the setup caller disables user extension * activation. Legacy callers may explicitly request the historical `pg_net` @@ -197,16 +192,6 @@ alter default privileges for role postgres in schema public revoke execute on functions from anon, authenticated, service_role; `; -/** - * Slim postgres leaves the `postgres` role as a non-superuser. Storage-api's - * vector-bucket migrations run as that role and `CREATE EXTENSION vector`, which - * docker.io postgres allows because `postgres` is a superuser there. - */ -export const LEGACY_START_SLIM_STORAGE_VECTOR_SQL = ` -ALTER ROLE postgres WITH SUPERUSER; -CREATE EXTENSION IF NOT EXISTS vector; -`; - /** * Exported for the shadow baseline cache's embedded-SQL digest (`shadow-cache.ts`), same as * {@link LEGACY_START_REVOKE_API_PRIVILEGES_SQL}: a webhooks-enabled baseline bakes this @@ -1111,16 +1096,6 @@ export const legacySetupDatabase = ( ); const requiresPg14WebhooksCleanup = input.majorVersion === 14; yield* legacyStartInitSchema(spawner, input, tmpDir); - if (input.config.storage.enabled && legacyUsesSlimRuntime(input.images.storage)) { - yield* legacyExecSqlConstant( - session, - fs, - path, - tmpDir, - "slim-storage-vector.sql", - LEGACY_START_SLIM_STORAGE_VECTOR_SQL, - ); - } if (requiresPg14WebhooksCleanup) { yield* legacyRemoveDatabaseWebhooks(session, fs, path, tmpDir); } 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 03a4a0e045..d0b281e139 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts @@ -399,59 +399,6 @@ describe("legacyStartSetupLocalDatabase", () => { }, ); - it.effect( - "slim storage: grants postgres SUPERUSER and creates the vector extension after initSchema", - () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const workdir = makeWorkdir(); - const { session, calls } = 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(() => { - const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); - expect(execSql.some((sql) => sql.includes("ALTER ROLE postgres WITH SUPERUSER"))).toBe( - true, - ); - expect( - execSql.some((sql) => sql.includes("CREATE EXTENSION IF NOT EXISTS vector")), - ).toBe(true); - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }, - ); - - it.effect("docker.io storage: does not grant postgres SUPERUSER for vector", () => { - const workdir = makeWorkdir(); - const { session, calls } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - return run(baseInput(workdir, session, { majorVersion: 15 }), out, docker).pipe( - Effect.map(() => { - const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); - expect(execSql.some((sql) => sql.includes("ALTER ROLE postgres WITH SUPERUSER"))).toBe( - false, - ); - expect(execSql.some((sql) => sql.includes("CREATE EXTENSION IF NOT EXISTS vector"))).toBe( - false, - ); - 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/postgres.service.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts index 81ef182374..e55fe24ca0 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts @@ -62,6 +62,18 @@ const LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH = "/etc/postgresql-custom/pgsodium_ */ const LEGACY_POSTGRES_SCHEMA_SQL_PATH = "/etc/postgresql.schema.sql"; +/** + * Slim `migrate.sh` demotes `postgres` after bundled migrations + * (`10000000000000_demote-postgres.sql`). The local CLI still talks to the + * cluster as that role, and storage-api's vector-bucket migrations + * `CREATE EXTENSION vector` as it — docker.io's local postgres stays a + * superuser. Re-promote and pre-create `vector` in the postinit hook, which + * runs as `supabase_admin` after demote and before the CLI connects. + */ +const LEGACY_POSTGRES_SLIM_POSTINIT_SQL = `ALTER ROLE postgres WITH SUPERUSER; +CREATE EXTENSION IF NOT EXISTS vector; +`; + /** Go's `container.HealthConfig` literals (`apps/cli-go/internal/db/start/start.go:85-90`). */ const LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS = 10; const LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS = 2; @@ -427,12 +439,12 @@ export function legacyIsSlimPostgresImage(image: string): boolean { } /** - * The bytes the docker.io entrypoint heredocs into - * {@link LEGACY_POSTGRES_SCHEMA_SQL_PATH} on PG >= 15 — schema.sql + webhook.sql + - * _supabase.sql, in that exact order, each terminated by the newline the heredoc - * body contributes (see {@link legacyPostgresEntrypointScriptPg15}). + * The bytes the slim image's `migrate.sh` runs as `supabase_admin` after bundled + * migrations: schema.sql + webhook.sql + _supabase.sql, then a slim-only postinit + * that re-promotes `postgres` (the bundled demote migration leaves it + * NOSUPERUSER) and creates `vector` for storage-api. */ -const LEGACY_POSTGRES_SLIM_SCHEMA_SQL = `${LEGACY_START_DB_SCHEMA_SQL}\n${LEGACY_START_DB_WEBHOOK_SQL}\n${LEGACY_START_DB_SUPABASE_SQL}\n`; +const LEGACY_POSTGRES_SLIM_SCHEMA_SQL = `${LEGACY_START_DB_SCHEMA_SQL}\n${LEGACY_START_DB_WEBHOOK_SQL}\n${LEGACY_START_DB_SUPABASE_SQL}\n${LEGACY_POSTGRES_SLIM_POSTINIT_SQL}`; /** * The image-dependent half of a Postgres container spec: how the entrypoint is 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 5c323a10e9..6ae08a68b3 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts @@ -533,7 +533,7 @@ describe("slim Postgres image spec", () => { { containerPath: "/etc/postgresql-custom/pgsodium_root.key", content: "custom-root-key" }, { containerPath: "/etc/postgresql.schema.sql", - content: `${LEGACY_START_DB_SCHEMA_SQL}\n${LEGACY_START_DB_WEBHOOK_SQL}\n${LEGACY_START_DB_SUPABASE_SQL}\n`, + content: `${LEGACY_START_DB_SCHEMA_SQL}\n${LEGACY_START_DB_WEBHOOK_SQL}\n${LEGACY_START_DB_SUPABASE_SQL}\nALTER ROLE postgres WITH SUPERUSER;\nCREATE EXTENSION IF NOT EXISTS vector;\n`, }, ]); }); From bcae49979a5d886cd304fa8520637f6a2187cb9f Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 26 Aug 2026 04:45:26 +0000 Subject: [PATCH 12/24] fix(cli): mount slim storage volume at /home/nonroot Distroless storage runs as uid 65532. A named volume at /mnt is created root-owned because that path is missing from the image, so mkdir /mnt/stub fails with EACCES. /home/nonroot is owned by the image user and Docker copies that ownership into an empty volume. Co-authored-by: Andrew Valleteau --- .../src/legacy/commands/start/SIDE_EFFECTS.md | 4 +- .../start/services/storage.service.ts | 58 ++++++++++++------- .../services/storage.service.unit.test.ts | 10 ++++ 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index c0e4694710..03d382fee7 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -116,7 +116,9 @@ always `CMD-SHELL` — and `legacyCheckContainerReady` treats `Running` as ready analytics keep a busybox `wget --spider` probe; slim Edge Runtime is started without `--entrypoint sh` (the wrapped binary has no shell) and the main-service template is copied to `/tmp/index.ts` (`--main-service=/tmp`) because the image runs as uid 65532 -and cannot read `/root`. Slim Postgres's `/etc/postgresql.schema.sql` postinit also +and cannot read `/root`. Slim Storage mounts its named volume at `/home/nonroot` +(owned by uid 65532 in the image) instead of `/mnt`, which Docker would create +root-owned. Slim Postgres's `/etc/postgresql.schema.sql` postinit also runs `ALTER ROLE postgres WITH SUPERUSER` plus `CREATE EXTENSION vector` as `supabase_admin` after bundled migrations demote `postgres` — storage-api's vector-bucket migrations create that extension as the `postgres` role. 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 76a8a3e920..2f24fa6177 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.ts @@ -48,6 +48,14 @@ import { /** Both the container's `FILE_STORAGE_BACKEND_PATH` and its named-volume mount target. */ const LEGACY_STORAGE_DOCKER_PATH = "/mnt"; +/** + * Distroless slim storage runs as uid 65532. `/mnt` does not exist in the + * image, so a named volume mounted there is created root-owned and + * `mkdir /mnt/stub` fails with EACCES. `/home/nonroot` is owned by that user; + * Docker copies the image directory into an empty named volume, so the + * tenant dir is writable. + */ +const LEGACY_STORAGE_SLIM_DOCKER_PATH = "/home/nonroot"; export interface LegacyStorageVectorEnvInput { /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ @@ -197,40 +205,46 @@ export interface LegacyStorageContainerSpecInput { /** * Builds the `docker create` spec for the Storage container. `binds` mounts - * the container's own named volume at `/mnt` — no `ports`/`exposedPorts`, - * Storage is reached only via its Docker network alias. + * the container's own named volume at `/mnt` (docker.io) or `/home/nonroot` + * (slim, uid 65532) — no `ports`/`exposedPorts`, Storage is reached only via + * its Docker network alias. */ export function legacyBuildStorageContainerSpec( input: LegacyStorageContainerSpecInput, ): LegacyStartContainerSpec { const containerName = legacyServiceContainerName("storage", input.projectId); - const env = legacyBuildStorageEnv({ - targetMigration: input.targetMigration, - anonKey: input.anonKey, - serviceRoleKey: input.serviceRoleKey, - jwtSecret: input.jwtSecret, - jwks: input.jwks, - dbHost: legacyServiceContainerName("db", input.projectId), - dbPassword: legacyStartInternalDbPassword(input.dbUrl), - fileSizeLimit: input.fileSizeLimit, - s3Region: input.s3Region, - s3AccessKeyId: input.s3AccessKeyId, - s3SecretAccessKey: input.s3SecretAccessKey, - imageTransformationEnabled: input.imageTransformationEnabled, - imgproxyHost: legacyServiceContainerName("imgproxy", input.projectId), - s3ProtocolEnabled: input.s3ProtocolEnabled, - vectorBucketsEnabled: input.vectorBucketsEnabled, - projectEnvValues: input.projectEnvValues, - }); + const slim = legacyUsesSlimRuntime(input.image); + const storagePath = slim ? LEGACY_STORAGE_SLIM_DOCKER_PATH : LEGACY_STORAGE_DOCKER_PATH; + const env = { + ...legacyBuildStorageEnv({ + targetMigration: input.targetMigration, + anonKey: input.anonKey, + serviceRoleKey: input.serviceRoleKey, + jwtSecret: input.jwtSecret, + jwks: input.jwks, + dbHost: legacyServiceContainerName("db", input.projectId), + dbPassword: legacyStartInternalDbPassword(input.dbUrl), + fileSizeLimit: input.fileSizeLimit, + s3Region: input.s3Region, + s3AccessKeyId: input.s3AccessKeyId, + s3SecretAccessKey: input.s3SecretAccessKey, + imageTransformationEnabled: input.imageTransformationEnabled, + imgproxyHost: legacyServiceContainerName("imgproxy", input.projectId), + s3ProtocolEnabled: input.s3ProtocolEnabled, + vectorBucketsEnabled: input.vectorBucketsEnabled, + projectEnvValues: input.projectEnvValues, + }), + FILE_STORAGE_BACKEND_PATH: storagePath, + }; return { image: input.image, containerName, env, - binds: [`${containerName}:${LEGACY_STORAGE_DOCKER_PATH}`], + binds: [`${containerName}:${storagePath}`], // Distroless slim storage has no /bin/sh; Docker CLI healthchecks are always // CMD-SHELL. Omitting makes `legacyCheckContainerReady` treat Running as ready. - ...(legacyUsesSlimRuntime(input.image) + ...(slim ? {} : { healthcheck: { diff --git a/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts index cd7a15ac10..7c96760858 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts @@ -266,4 +266,14 @@ describe("legacyBuildStorageContainerSpec", () => { }); expect(spec.healthcheck).toBeUndefined(); }); + + test("mounts the named volume at /home/nonroot on a slim image so uid 65532 can mkdir the tenant dir", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildStorageContainerSpec({ + ...input, + image: "ghcr.io/supabase/cli/storage:v1.70.3", + }); + expect(spec.binds).toEqual(["supabase_storage_proj:/home/nonroot"]); + expect(spec.env["FILE_STORAGE_BACKEND_PATH"]).toBe("/home/nonroot"); + }); }); From 01e32ba4c2b00c39b3d90de57e285b6c6fffe148 Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 26 Aug 2026 07:48:25 +0000 Subject: [PATCH 13/24] fix(docker): bump supavisor from 2.9.7 to 2.9.10 Align the embedded pooler pin with the stack catalog and the oldest published slim ghcr.io/supabase/cli/pooler tag. Co-authored-by: Andrew Valleteau --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/slim-images.unit.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index f24a2d1043..06fe9d8051 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -9,7 +9,7 @@ FROM supabase/studio:2026.08.17-sha-0c1da8f AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.3 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector -FROM supabase/supavisor:2.9.7 AS supavisor +FROM supabase/supavisor:2.9.10 AS supavisor FROM supabase/gotrue:v2.196.0 AS gotrue FROM supabase/realtime:v2.129.3 AS realtime FROM supabase/storage-api:v1.70.3 AS storage diff --git a/apps/cli/src/shared/services/slim-images.unit.test.ts b/apps/cli/src/shared/services/slim-images.unit.test.ts index 81539ca5a1..f898b41b28 100644 --- a/apps/cli/src/shared/services/slim-images.unit.test.ts +++ b/apps/cli/src/shared/services/slim-images.unit.test.ts @@ -50,8 +50,8 @@ describe("toSlimImage", () => { }); it("v-prefixes pins whose slim tag scheme differs from docker.io's", () => { - expect(toSlimImage("supavisor", "supabase/supavisor:2.9.7")).toBe( - "ghcr.io/supabase/cli/pooler:v2.9.7", + 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", From a83e369fe8d46b288a7887e25e9e6deba76ef0b1 Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 26 Aug 2026 11:30:53 +0200 Subject: [PATCH 14/24] fix(cli): keep shell jobs off distroless slim images and gate storage seeding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three slim-runtime gaps from PR review, each verified against the real ghcr images (both distroless: no sh/busybox anywhere in their layers): - The pg-delta legacy engine's edge-runtime script runner replaces the entrypoint with sh to heredoc its files, which cannot run on the slim edge-runtime image — it now resolves through a docker.io-pinned variant of the shared resolver (version pin still honored), the same locked-exception shape as the deno1 tag. - The slim storage image cannot carry a docker healthcheck, so bulk readiness accepted it at Running and bucket seeding raced its migrations — the health wait now probes HEAD /storage/v1/status through Kong on the slim path (including the --ignore-health-check storage recheck), skipped when kong is excluded; docker.io keeps the container healthcheck byte-identically. - The slim functions-serve multiline-secret rejection was a bare Error; it is now an exported tagged error classified invalidConfig, failing through the Effect channel. Co-Authored-By: Claude Fable 5 --- .../legacy/commands/bootstrap/SIDE_EFFECTS.md | 26 +++---- .../legacy/commands/db/push/SIDE_EFFECTS.md | 2 +- .../legacy/commands/db/reset/SIDE_EFFECTS.md | 2 +- .../legacy/commands/db/start/SIDE_EFFECTS.md | 42 +++++------ .../src/legacy/commands/start/SIDE_EFFECTS.md | 34 +++++---- .../edge-runtime.service.integration.test.ts | 26 +++++++ .../start/services/storage.service.ts | 7 +- .../legacy/commands/start/start.handler.ts | 56 ++++++++++---- .../commands/start/start.integration.test.ts | 31 +++++++- .../shared/db-bootstrap/health-check.ts | 16 ++++ .../db-bootstrap/health-check.unit.test.ts | 73 +++++++++++++++++++ .../shared/legacy-edge-runtime-image.ts | 47 +++++++++--- .../legacy-edge-runtime-image.unit.test.ts | 39 +++++++++- ...e-runtime-script.layer.integration.test.ts | 23 ++++++ .../legacy-edge-runtime-script.layer.ts | 6 +- apps/cli/src/shared/functions/serve.errors.ts | 24 ++++++ apps/cli/src/shared/functions/serve.ts | 8 +- 17 files changed, 376 insertions(+), 86 deletions(-) create mode 100644 apps/cli/src/shared/functions/serve.errors.ts diff --git a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md index 865f8b9108..13053411a6 100644 --- a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md @@ -69,19 +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 | -| `SUPABASE_USE_SLIM_IMAGES` | legacy opt-out's edge-runtime image resolves from the slim `ghcr.io/supabase/cli/edge-runtime` build; ambient shell only, unlike the neighboring project-`.env`-aware vars | 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/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index d1139cba1f..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,7 +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` | resolves the pg-delta edge-runtime image from the slim `ghcr.io/supabase/cli/edge-runtime` build for the cache export; ambient shell only, unlike the neighboring project-`.env`-aware vars | no (ambient shell only) | +| `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 e9bde67484..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,7 +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` | resolves the pg-delta edge-runtime image from the slim `ghcr.io/supabase/cli/edge-runtime` build for the migrations-catalog cache export; ambient shell only, unlike the neighboring project-`.env`-aware vars | no (ambient shell only) | +| `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/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md index 0c6bb3d36d..34cbf9e502 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -116,27 +116,27 @@ native container command in this codebase — never `supabase-go`. ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_PROJECT_ID` | overrides the local container id | no | -| `SUPABASE_USE_SLIM_IMAGES` | resolves every service image from the slim `ghcr.io/supabase/cli/` builds instead of the docker.io Dockerfile pins (`true`/`1` enable); the pg 13/14/15 fallbacks and OrioleDB images 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 | +| Variable | Purpose | Required? | +| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_PROJECT_ID` | overrides the local container id | no | +| `SUPABASE_USE_SLIM_IMAGES` | resolves every service image from the slim `ghcr.io/supabase/cli/` builds instead of the docker.io Dockerfile pins (`true`/`1` enable); the pg 13/14/15 fallbacks, OrioleDB images, and the pg-delta catalog-warmup container (its script needs an `sh -c` entrypoint the distroless build has no shell for) stay on docker.io | no | +| `SUPABASE_DB_PORT` | overrides `db.port` (the published host port) | no | +| `SUPABASE_DB_MAJOR_VERSION` | overrides `db.major_version` (image selection, schema branch) | no | +| `SUPABASE_DB_HEALTH_TIMEOUT` | overrides `db.health_timeout` | no | +| `SUPABASE_DB_SETTINGS_*` | overrides individual `[db.settings]` fields | no | +| `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` | overrides `experimental.orioledb_version` (image + env) | no | +| `SUPABASE_EXPERIMENTAL_S3_{HOST,REGION,ACCESS_KEY,SECRET_KEY}` | OrioleDB S3 env overrides | no | +| `SUPABASE_REALTIME_ENABLED` | gates the fresh-volume realtime migrate job | no | +| `SUPABASE_REALTIME_IP_VERSION` / `_MAX_HEADER_LENGTH` | realtime migrate job env overrides | no | +| `SUPABASE_STORAGE_ENABLED` | gates the fresh-volume storage migrate job | no | +| `SUPABASE_STORAGE_FILE_SIZE_LIMIT` | storage migrate job env override | no | +| `SUPABASE_AUTH_ENABLED` | gates the fresh-volume auth migrate job | no | +| `SUPABASE_AUTH_EXTERNAL_URL` / `SUPABASE_AUTH_SITE_URL` | auth migrate job env overrides | no | +| `SUPABASE_AUTH_JWT_EXPIRY` | Postgres's `JWT_EXP` env / signing | no | +| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | +| `SUPABASE_USE_PG_DELTA_NEXT` | selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache warmup (unset/unrecognized defaults to the next engine, which skips it) | no | +| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file, installed into the process environment before any Docker work) to pick the Docker daemon this whole command talks to | no | `--network-id` (a global CLI flag, not an environment variable — `shared/legacy/global-flags.ts`) forces every created container/network onto that Docker network instead of the generated diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 03d382fee7..1ad3c6284c 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -112,7 +112,11 @@ The same flag also keeps Vector's image entrypoint (`vector --config /etc/vector and copies `vector.yaml` via `secretFiles` instead of a `sh` heredoc, because the slim Vector image has no shell-based entrypoint override. Distroless slim services with no `/bin/sh` (auth, storage, studio, pg-meta) omit Docker healthchecks — `docker create --health-cmd` is -always `CMD-SHELL` — and `legacyCheckContainerReady` treats `Running` as ready. Realtime and +always `CMD-SHELL` — and `legacyCheckContainerReady` treats `Running` as ready; slim Storage +is the exception, gated instead on a `HEAD /storage/v1/status` probe through Kong (the same +gateway shape PostgREST and Edge Runtime use) so bucket seeding below cannot race its +startup. That probe is skipped when Kong itself is `--exclude`d, since nothing then routes +to Storage. Realtime and analytics keep a busybox `wget --spider` probe; slim Edge Runtime is started without `--entrypoint sh` (the wrapped binary has no shell) and the main-service template is copied to `/tmp/index.ts` (`--main-service=/tmp`) because the image runs as uid 65532 @@ -179,20 +183,20 @@ not implemented. ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_*` (any dotted config field) | Generic Viper-style `AutomaticEnv` override of any `config.toml` field (e.g. `SUPABASE_AUTH_ENABLED`, `SUPABASE_API_PORT`) | no | -| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | Fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` (see "Fresh-volume DB setup" above) | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | Enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | Selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache warmup (unset/unrecognized defaults to the next engine, which skips it) | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | Overrides the image registry used to resolve every service's image | no | -| `SUPABASE_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, 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 | +| 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 ab1fe4225f..2b95632647 100644 --- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, vi } from "vitest"; import { useLegacyTempWorkdir } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { SlimEdgeRuntimeMultilineSecretError } from "../../../../shared/functions/serve.errors.ts"; import { legacyStartEdgeRuntimeContainer, type LegacyEdgeRuntimeBringUpInput, @@ -389,6 +390,31 @@ describe("legacyStartEdgeRuntimeContainer", () => { }), ); + it.effect( + "slim edge-runtime: refuses a multiline secret it has no shell to source, before creating anything", + () => + Effect.gen(function* () { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const mock = mockDockerSpawner(); + const out = mockOutput(); + const input = { + ...baseInput(tempWorkdir.current), + image: "ghcr.io/supabase/cli/edge-runtime:v1.74.2", + edgeRuntimeSecrets: { MULTI_LINE_KEY: "-----BEGIN KEY-----\nsecret\n-----END KEY-----" }, + }; + + const error = yield* legacyStartEdgeRuntimeContainer(input).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(SlimEdgeRuntimeMultilineSecretError); + expect(String(error)).toContain("cannot source multiline function secrets"); + expect(mock.runCall).toBeUndefined(); + }), + ); + it.effect( "surfaces docker's own stderr verbatim and never reaches cp/start when docker create fails", () => diff --git a/apps/cli/src/legacy/commands/start/services/storage.service.ts b/apps/cli/src/legacy/commands/start/services/storage.service.ts index 2f24fa6177..3153c30f0b 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.ts @@ -242,8 +242,11 @@ export function legacyBuildStorageContainerSpec( containerName, env, binds: [`${containerName}:${storagePath}`], - // Distroless slim storage has no /bin/sh; Docker CLI healthchecks are always - // CMD-SHELL. Omitting makes `legacyCheckContainerReady` treat Running as ready. + // Distroless slim storage has no /bin/sh (nor wget) and Docker CLI + // healthchecks are always CMD-SHELL, so there is no probe to declare here. + // `start` gates readiness on Kong's `/storage/v1/status` instead — see + // `health-check.ts` — because `legacyCheckContainerReady` would otherwise + // accept a merely-Running container. ...(slim ? {} : { diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 2d6034999d..6d802ed365 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -178,6 +178,7 @@ import { import { legacyBuildMailpitContainerSpec } from "./services/mailpit.service.ts"; import { legacyBuildRealtimeContainerSpec } from "./services/realtime.service.ts"; import { LEGACY_REALTIME_TENANT_ID } from "../../shared/db-bootstrap/realtime-env.ts"; +import { legacyUsesSlimRuntime } from "../../shared/db-bootstrap/slim-runtime.ts"; import { legacyBuildPostgrestContainerSpec } from "./services/postgrest.service.ts"; import { legacyBuildStorageContainerSpec } from "./services/storage.service.ts"; import { legacyBuildImgproxyContainerSpec } from "./services/imgproxy.service.ts"; @@ -1667,6 +1668,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta const started = new Map(); let postgrestGateway: LegacyHealthCheckPostgrestGateway | undefined; let edgeRuntimeGateway: LegacyHealthCheckPostgrestGateway | undefined; + let storageGateway: LegacyHealthCheckPostgrestGateway | undefined; let storageContainerId: string | undefined; const imagePlanByService = new Map(imagePlan.map((entry) => [entry.service, entry.image])); for (const entry of LEGACY_START_SERVICES) { @@ -1815,6 +1817,18 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta } if (entry.service === "storage") { storageContainerId = spec.containerName; + // The slim Storage spec declares no Docker healthcheck (distroless — + // see `storage.service.ts`), so without this gateway the health wait + // would accept it as soon as it is `Running` and bucket seeding below + // would race its startup. Needs Kong to reach it; with Kong excluded + // there is no route to probe, so fall back to the Running-only check. + if (legacyUsesSlimRuntime(spec.image) && gates.kong) { + storageGateway = { + containerId: spec.containerName, + apiExternalUrl: values.apiUrl, + secretKey: values.secretKey, + }; + } } } @@ -1823,6 +1837,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta started, postgrestGateway, edgeRuntimeGateway, + storageGateway, storageContainerId, }; }).pipe( @@ -1886,7 +1901,8 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // but never re-entering these later steps once the DB bootstrap has // already returned. if (bringUpResult.kind === "started") { - const { started, postgrestGateway, edgeRuntimeGateway, storageContainerId } = bringUpResult; + const { started, postgrestGateway, edgeRuntimeGateway, storageGateway, storageContainerId } = + bringUpResult; // Wraps steps 9-11 below (bulk health wait, the ignore-health-check // storage-only recheck-and-seed, the success-path bucket seeding, and @@ -1980,18 +1996,27 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta projectRef: "", config: effectiveLocalStorageConfig, }); + // Shared by every gateway probe below (the bulk wait and the + // storage-only recheck), so both trust the same local Kong CA. + const withLocalKongCa = (effect: Effect.Effect) => + localKongCa === undefined + ? effect + : effect.pipe( + Effect.provideService( + FetchHttpClient.Fetch, + legacyStorageGatewayFetch(localKongCa), + ), + ); // Keep the synthetic value out of project dotenv resolution and container environments. legacyConfigureLoopbackProxyBypass(); - const healthResult = yield* legacyWaitForHealthyServices(spawner, [...started.keys()], { - postgrest: postgrestGateway, - edgeRuntime: edgeRuntimeGateway, - images: started, - }).pipe( - Effect.result, - localKongCa !== undefined - ? Effect.provideService(FetchHttpClient.Fetch, legacyStorageGatewayFetch(localKongCa)) - : (effect) => effect, - ); + const healthResult = yield* withLocalKongCa( + legacyWaitForHealthyServices(spawner, [...started.keys()], { + postgrest: postgrestGateway, + edgeRuntime: edgeRuntimeGateway, + storage: storageGateway, + images: started, + }), + ).pipe(Effect.result); if (Result.isFailure(healthResult)) { const error = healthResult.failure; if (flags.ignoreHealthCheck && legacyIsUnhealthyStartError(error)) { @@ -2012,10 +2037,11 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // `images` is intentionally the whole run's registry, not scoped to // this one-container watch list — the hint can only ever key off // containers that actually appear in this call's own failures. - const storageHealthResult = yield* legacyWaitForHealthyServices( - spawner, - [storageContainerId], - { images: started }, + const storageHealthResult = yield* withLocalKongCa( + legacyWaitForHealthyServices(spawner, [storageContainerId], { + storage: storageGateway, + images: started, + }), ).pipe(Effect.result); if (Result.isSuccess(storageHealthResult)) { const seedResult = yield* legacySeedBucketsRun({ diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts index 10de9bd618..c48d05eea1 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -322,9 +322,12 @@ function freshVolumeRoute( function mockStorageBucketHttpClient() { const createdBucketRequests: Array = []; const createdBucketBodies: Array = []; + /** Every request in order, so a test can assert a readiness probe preceded seeding. */ + const requests: Array<{ method: string; url: string }> = []; const layer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => { + requests.push({ method: request.method, url: request.url }); if (request.method === "GET" && request.url.includes("/storage/v1/bucket")) { return Effect.succeed( HttpClientResponse.fromWeb( @@ -362,7 +365,7 @@ function mockStorageBucketHttpClient() { ); }), ); - return { layer, createdBucketRequests, createdBucketBodies }; + return { layer, createdBucketRequests, createdBucketBodies, requests }; } /** @@ -2645,9 +2648,35 @@ content_path = "./supabase/templates/custom_notice.html" return Effect.gen(function* () { yield* legacyStart(flags({ exclude: ["edge-runtime"] })); expect(http.createdBucketRequests).toHaveLength(1); + // docker.io Storage carries its own Docker healthcheck, so readiness + // never goes through the gateway. + expect(http.requests.some((entry) => entry.url.includes("/storage/v1/status"))).toBe(false); }).pipe(Effect.provide(layer)); }); + it.live("probes storage's own /status before seeding a bucket on the slim image", () => { + // The slim Storage spec carries no Docker healthcheck, so without the + // gateway probe seeding would fire the moment the container is Running. + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const http = mockStorageBucketHttpClient(); + const { layer } = setup({ + configContents: 'project_id = "demo"\n[storage.buckets.avatars]\npublic = false\n', + route: freshVolumeRoute(defaultRoute()), + httpClientLayer: http.layer, + }); + return Effect.gen(function* () { + yield* legacyStart(flags({ exclude: ["edge-runtime"] })); + const probeIndex = http.requests.findIndex( + (entry) => entry.method === "HEAD" && entry.url.includes("/storage/v1/status"), + ); + const seedIndex = http.requests.findIndex( + (entry) => entry.method === "POST" && entry.url.includes("/storage/v1/bucket"), + ); + expect(probeIndex).toBeGreaterThanOrEqual(0); + expect(seedIndex).toBeGreaterThan(probeIndex); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }); + it.live( "does not seed a configured bucket on a non-fresh volume, even with storage enabled", () => { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts b/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts index f791a6f6fe..4c3f7a35cf 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts @@ -57,6 +57,16 @@ const LEGACY_POSTGREST_READY_PATH = "/rest-admin/v1/ready"; */ const LEGACY_EDGE_RUNTIME_READY_PATH = "/functions/v1/_internal/health"; +/** + * Storage's own `/status` (the endpoint its docker.io HEALTHCHECK probes with + * `wget --spider`), reached through Kong's `/storage/v1/` route. Only the slim + * image needs it: distroless Storage carries no Docker healthcheck (no shell + * for `CMD-SHELL`, see `slim-runtime.ts`), so {@link legacyCheckContainerReady} + * would report it ready the moment it is `Running` — before it can answer the + * bucket-seeding calls `start` makes right after this gate. + */ +const LEGACY_STORAGE_READY_PATH = "/storage/v1/status"; + /** Identifies a single container's readiness failure this round. */ export interface LegacyHealthCheckFailure { /** @@ -138,6 +148,8 @@ export interface LegacyWaitForHealthyServicesOptions { readonly postgrest?: LegacyHealthCheckPostgrestGateway; /** See {@link LEGACY_EDGE_RUNTIME_READY_PATH}'s doc comment for why this reuses the same gateway shape as {@link postgrest}. */ readonly edgeRuntime?: LegacyHealthCheckPostgrestGateway; + /** Set only for the slim Storage image — see {@link LEGACY_STORAGE_READY_PATH}. */ + readonly storage?: LegacyHealthCheckPostgrestGateway; /** Each watched container's already-resolved image, keyed by container name. */ readonly images?: ReadonlyMap; } @@ -350,6 +362,7 @@ export function legacyWaitForHealthyServices( const timeoutSeconds = opts.timeoutSeconds ?? LEGACY_HEALTH_CHECK_TIMEOUT_SECONDS; const postgrest = opts.postgrest; const edgeRuntime = opts.edgeRuntime; + const storage = opts.storage; const checkOne = (containerId: string): Effect.Effect => { if (postgrest !== undefined && containerId === postgrest.containerId) { @@ -358,6 +371,9 @@ export function legacyWaitForHealthyServices( if (edgeRuntime !== undefined && containerId === edgeRuntime.containerId) { return legacyCheckHttpReady(edgeRuntime, LEGACY_EDGE_RUNTIME_READY_PATH); } + if (storage !== undefined && containerId === storage.containerId) { + return legacyCheckHttpReady(storage, LEGACY_STORAGE_READY_PATH); + } return legacyCheckContainerReady(spawner, containerId); }; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts index 731d96751a..6167c3ac55 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts @@ -151,6 +151,8 @@ const runningStarting = JSON.stringify({ Health: { Status: "starting" }, }); const notRunning = JSON.stringify({ Status: "exited", Running: false }); +/** A container with no Docker healthcheck at all, like every distroless slim image. */ +const runningNoHealth = JSON.stringify({ Status: "running", Running: true }); /** * `legacyWaitForHealthyServices` structurally requires `HttpClient.HttpClient` @@ -653,6 +655,77 @@ describe("legacyWaitForHealthyServices", () => { ); }); + describe("slim Storage HTTP-HEAD readiness", () => { + const storageGateway: LegacyHealthCheckPostgrestGateway = { + containerId: "supabase_storage_proj", + apiExternalUrl: "http://127.0.0.1:54321", + secretKey: "sb_secret_local", + }; + + function httpLayer(status: number) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.method).toBe("HEAD"); + expect(request.url).toBe("http://127.0.0.1:54321/storage/v1/status"); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status })), + ); + }), + ); + } + + it.effect("succeeds on a 200 instead of trusting a merely-Running container", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningNoHealth); + + const exit = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_storage_proj"], { + timeoutSeconds: 1, + storage: storageGateway, + }).pipe(Effect.provide(httpLayer(200)), Effect.exit); + + expect(Exit.isSuccess(exit)).toBe(true); + expect(inspectCalls(mock)).toHaveLength(0); + }), + ); + + it.effect("keeps waiting while Storage is still starting up", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningNoHealth); + + const fiber = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_storage_proj"], { + timeoutSeconds: 1, + storage: storageGateway, + }).pipe( + Effect.provide(httpLayer(503)), + withSilencedStderr, + Effect.forkChild({ startImmediately: true }), + ); + + yield* TestClock.adjust("1 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyHealthCheckTimeoutError); + expect(error.unhealthy).toEqual([ + { containerId: "supabase_storage_proj", reason: "unexpected status 503" }, + ]); + }), + ); + + it.effect("falls back to the Docker state when no gateway is configured", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningNoHealth); + + const exit = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_storage_proj"], { + timeoutSeconds: 1, + }).pipe(Effect.provide(unusedHttpClientLayer), Effect.exit); + + expect(Exit.isSuccess(exit)).toBe(true); + expect(inspectCalls(mock)).toHaveLength(1); + }), + ); + }); + describe("Edge Runtime HTTP-HEAD readiness", () => { function edgeRuntimeGateway(secretKey: string): LegacyHealthCheckPostgrestGateway { return { diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts index 1b3ce48062..6670386345 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts @@ -1,6 +1,9 @@ import { Effect, type FileSystem, type Path } from "effect"; import { DENO1_EDGE_RUNTIME_VERSION } from "../../shared/functions/functions.shared.ts"; -import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; +import { + dockerfileServiceImage, + dockerfileServiceImageRaw, +} from "../../shared/services/dockerfile-images.ts"; /** * Resolves the edge-runtime Docker image the way Go's `config.Load` does @@ -28,20 +31,14 @@ function replaceImageTag(image: string, tag: string): string { return image.slice(0, index + 1) + tag.trim(); } -/** - * Resolve the edge-runtime image, honoring the pinned tag in - * `supabase/.temp/edge-runtime-version` and the `deno_version` selector - * (default 2 → Dockerfile image; 1 → `deno1`). The version pin is applied first - * (Go's `Load`), then `deno_version = 1` overrides to `deno1` (Go's validate - * pass). - */ -export const legacyResolveEdgeRuntimeImage = Effect.fnUntraced(function* ( +const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( + baseImage: string, fs: FileSystem.FileSystem, path: Path.Path, workdir: string, denoVersion: number, ) { - let image = legacyEdgeRuntimeImage(); + let image = baseImage; const versionPath = path.join(workdir, "supabase", ".temp", "edge-runtime-version"); const pinned = yield* fs.readFileString(versionPath).pipe( Effect.map((s) => s.trim()), @@ -54,10 +51,38 @@ export const legacyResolveEdgeRuntimeImage = Effect.fnUntraced(function* ( image = pinned === DENO1_EDGE_RUNTIME_VERSION ? LEGACY_EDGE_RUNTIME_DENO1_IMAGE - : replaceImageTag(legacyEdgeRuntimeImage(), pinned); + : replaceImageTag(baseImage, pinned); } if (denoVersion === 1) { image = LEGACY_EDGE_RUNTIME_DENO1_IMAGE; } return image; }); + +/** + * Resolve the edge-runtime image, honoring the pinned tag in + * `supabase/.temp/edge-runtime-version` and the `deno_version` selector + * (default 2 → Dockerfile image; 1 → `deno1`). The version pin is applied first + * (Go's `Load`), then `deno_version = 1` overrides to `deno1` (Go's validate + * pass). + */ +export const legacyResolveEdgeRuntimeImage = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + denoVersion: number, +) => resolveEdgeRuntimeImage(legacyEdgeRuntimeImage(), fs, path, workdir, denoVersion); + +/** + * Same resolution pinned to docker.io, for callers that replace the image + * entrypoint with a shell. The slim edge-runtime image is distroless: its only + * executables are `/usr/bin/edge-runtime` and its wrapper, so `sh -c …` cannot + * run there at all — the same locked exception the `deno1` tag already carries. + */ +export const legacyResolveEdgeRuntimeShellImage = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + denoVersion: number, +) => + resolveEdgeRuntimeImage(dockerfileServiceImageRaw("edgeruntime"), fs, path, workdir, denoVersion); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts index 4fa3e13b21..0d93470c37 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts @@ -7,7 +7,10 @@ import { Effect, FileSystem, Path } from "effect"; import { vi } from "vitest"; import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; -import { legacyResolveEdgeRuntimeImage } from "./legacy-edge-runtime-image.ts"; +import { + legacyResolveEdgeRuntimeImage, + legacyResolveEdgeRuntimeShellImage, +} from "./legacy-edge-runtime-image.ts"; const resolve = (workdir: string, denoVersion: number) => Effect.gen(function* () { @@ -16,6 +19,13 @@ const resolve = (workdir: string, denoVersion: number) => return yield* legacyResolveEdgeRuntimeImage(fs, path, workdir, denoVersion); }).pipe(Effect.provide(BunServices.layer)); +const resolveShell = (workdir: string, denoVersion: number) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyResolveEdgeRuntimeShellImage(fs, path, workdir, denoVersion); + }).pipe(Effect.provide(BunServices.layer)); + describe("legacyResolveEdgeRuntimeImage", () => { it.effect("returns the edge-runtime image from the Dockerfile when nothing is pinned", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); @@ -29,6 +39,18 @@ describe("legacyResolveEdgeRuntimeImage", () => { ); }); + it.effect("resolves the shell-pinned variant to the same image while the flag is off", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); + return resolveShell(dir, 2).pipe( + Effect.tap((image) => + Effect.sync(() => { + expect(image).toBe(dockerfileServiceImage("edgeruntime")); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("honors the pinned tag in .temp/edge-runtime-version", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); @@ -75,6 +97,21 @@ describe("legacyResolveEdgeRuntimeImage", () => { ); }); + it.effect("keeps the shell-pinned resolution on docker.io, pin included", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); + mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(dir, "supabase", ".temp", "edge-runtime-version"), "v9.9.9\n"); + return resolveShell(dir, 2).pipe( + Effect.tap((image) => + Effect.sync(() => { + expect(image).toBe("supabase/edge-runtime:v9.9.9"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("keeps a deno1-tag pin on docker.io, where that tag exists", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts index 41bdcd6ba4..282a1824b0 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, Exit, Layer, Option } from "effect"; +import { vi } from "vitest"; import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; @@ -192,6 +193,28 @@ describe("legacyEdgeRuntimeScriptLayer sentinel handling", () => { }, ); + it.effect("keeps the runner on the docker.io image with the slim-images flag on", () => { + // The runner replaces the entrypoint with `sh -c `; the distroless + // slim image ships no shell, so it must never be selected here. + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const { layer, docker } = setup({ + exitCode: 1, + stdout: "", + stderr: "main worker has been destroyed\n", + }); + return runScript().pipe( + Effect.tap(() => + Effect.sync(() => { + expect(docker.lastOpts?.entrypoint).toStrictEqual(Option.some("sh")); + expect(docker.lastOpts?.image).not.toContain("ghcr.io/supabase/cli/"); + expect(docker.lastOpts?.image).toContain("edge-runtime:"); + }), + ), + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), + ); + }); + it.effect( "disables SELinux label separation so the container can read CLI-written workspace files", () => { diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts index 17e1a65d6f..9146fd38e5 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts @@ -7,7 +7,7 @@ import { LegacyCliSettings } from "../config/legacy-cli-settings.service.ts"; import { legacyReadDbToml } from "./legacy-db-config.toml-read.ts"; import { legacyGetRegistryImageUrl } from "./legacy-docker-registry.ts"; import { LegacyDockerRun } from "./legacy-docker-run.service.ts"; -import { legacyResolveEdgeRuntimeImage } from "./legacy-edge-runtime-image.ts"; +import { legacyResolveEdgeRuntimeShellImage } from "./legacy-edge-runtime-image.ts"; import { LegacyEdgeRuntimeScriptError } from "./legacy-edge-runtime-script.errors.ts"; import { LEGACY_EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL, @@ -101,8 +101,10 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( (error) => new LegacyEdgeRuntimeScriptError({ message: error.message }), ), )).denoVersion; + // Shell-pinned resolution: this runner delivers `index.ts` through an + // `sh -c` here-document, which the distroless slim image cannot run. const registryImage = legacyGetRegistryImageUrl( - yield* legacyResolveEdgeRuntimeImage(fs, path, workdir, denoVersion), + yield* legacyResolveEdgeRuntimeShellImage(fs, path, workdir, denoVersion), ); const port = yield* allocateFreeHostPort; const startCmd = legacyBuildEdgeRuntimeStartCmd({ port, debug }).join(" "); diff --git a/apps/cli/src/shared/functions/serve.errors.ts b/apps/cli/src/shared/functions/serve.errors.ts new file mode 100644 index 0000000000..9f16bbed29 --- /dev/null +++ b/apps/cli/src/shared/functions/serve.errors.ts @@ -0,0 +1,24 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +/** + * A newline-containing function secret while `SUPABASE_USE_SLIM_IMAGES` is on. + * Multiline values reach the container through a sourced shell script, and the + * slim edge-runtime image is distroless — it ships no shell to source it. + */ +export class SlimEdgeRuntimeMultilineSecretError extends Data.TaggedError( + "SlimEdgeRuntimeMultilineSecretError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The remediation is editing the env file or the flag, not re-running with + // different arguments, so `invalidConfig` (which declares its + // update-config suggestion) fits better than `invalidInput`. + return actionability.invalidConfig; + } +} diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 1b7d970dc0..2b3b79a24a 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -75,6 +75,7 @@ import { toDockerPath, } from "./functions-docker.ts"; import { loadFunctionsCliConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; +import { SlimEdgeRuntimeMultilineSecretError } from "./serve.errors.ts"; import { edgeRuntimeImage, resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; import { usesSlimImageRuntime } from "../services/slim-images.ts"; const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); @@ -1774,9 +1775,10 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo ]; if (slimEdgeRuntime && dockerMultilineEnvScript !== undefined) { return yield* Effect.fail( - new Error( - "SUPABASE_USE_SLIM_IMAGES cannot source multiline function secrets: the slim edge-runtime image has no shell. Unset the flag, or remove newline-containing values from the functions env file.", - ), + new SlimEdgeRuntimeMultilineSecretError({ + message: + "SUPABASE_USE_SLIM_IMAGES cannot source multiline function secrets: the slim edge-runtime image has no shell. Unset the flag, or remove newline-containing values from the functions env file.", + }), ); } const serveMainTemplate = yield* Effect.promise(() => getLegacyFunctionsServeMainTemplate()); From 587d56e4305154fbf7e39a20cd185f40085ab998 Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 26 Aug 2026 14:30:28 +0200 Subject: [PATCH 15/24] fix(docker): bump postgres from 17.6.1.165 to 17.6.1.166 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 17.6.1.166 slim build ships the full supautils policy (privileged extensions allowlist incl. pg_net and vector, privileged_extensions_superuser = supabase_admin), unblocking the shadow-database flows under SUPABASE_USE_SLIM_IMAGES — verified live: create extension pg_net succeeds as the demoted postgres role, and db diff runs its slim shadow end to end. The postgres demote is still present in the bundle migrations, so the CLI postinit re-promotion stays. docker.io publishes the same tag, so the flag-off pin moves in lockstep. Co-Authored-By: Claude Fable 5 --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 06fe9d8051..c530a20865 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 From 59e3368ecbc05c1d1366b9e2a9966b33ae785151 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 27 Aug 2026 11:24:49 +0200 Subject: [PATCH 16/24] fix(cli): drop the slim-only postgres postinit re-promotion Both image families run the same bundled migrations, including the unconditional postgres demotion, so re-promoting postgres on slim made the two families diverge (rolsuper=t on slim vs f on docker.io). The vector pre-create it carried was never needed either: storage-api creates the extension itself in its own storage_vectors database through the supautils allowlist, which the shared config recipe now ships on slim. Verified by a full pg_settings/pg_roles/pg_extension parity diff between a slim and a docker.io stack of the same release: roles and extensions diff empty, postgres rolsuper=f on both, cron/vault custom-script grants intact. Co-Authored-By: Claude Fable 5 --- .../shared/db-bootstrap/postgres.service.ts | 20 ++++--------------- .../postgres.service.unit.test.ts | 2 +- 2 files changed, 5 insertions(+), 17 deletions(-) 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 e55fe24ca0..856a035f67 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts @@ -62,18 +62,6 @@ const LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH = "/etc/postgresql-custom/pgsodium_ */ const LEGACY_POSTGRES_SCHEMA_SQL_PATH = "/etc/postgresql.schema.sql"; -/** - * Slim `migrate.sh` demotes `postgres` after bundled migrations - * (`10000000000000_demote-postgres.sql`). The local CLI still talks to the - * cluster as that role, and storage-api's vector-bucket migrations - * `CREATE EXTENSION vector` as it — docker.io's local postgres stays a - * superuser. Re-promote and pre-create `vector` in the postinit hook, which - * runs as `supabase_admin` after demote and before the CLI connects. - */ -const LEGACY_POSTGRES_SLIM_POSTINIT_SQL = `ALTER ROLE postgres WITH SUPERUSER; -CREATE EXTENSION IF NOT EXISTS vector; -`; - /** Go's `container.HealthConfig` literals (`apps/cli-go/internal/db/start/start.go:85-90`). */ const LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS = 10; const LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS = 2; @@ -440,11 +428,11 @@ export function legacyIsSlimPostgresImage(image: string): boolean { /** * The bytes the slim image's `migrate.sh` runs as `supabase_admin` after bundled - * migrations: schema.sql + webhook.sql + _supabase.sql, then a slim-only postinit - * that re-promotes `postgres` (the bundled demote migration leaves it - * NOSUPERUSER) and creates `vector` for storage-api. + * migrations: schema.sql + webhook.sql + _supabase.sql — the same postinit both + * image families run (the bundled demote migration leaves `postgres` + * NOSUPERUSER on both). */ -const LEGACY_POSTGRES_SLIM_SCHEMA_SQL = `${LEGACY_START_DB_SCHEMA_SQL}\n${LEGACY_START_DB_WEBHOOK_SQL}\n${LEGACY_START_DB_SUPABASE_SQL}\n${LEGACY_POSTGRES_SLIM_POSTINIT_SQL}`; +const LEGACY_POSTGRES_SLIM_SCHEMA_SQL = `${LEGACY_START_DB_SCHEMA_SQL}\n${LEGACY_START_DB_WEBHOOK_SQL}\n${LEGACY_START_DB_SUPABASE_SQL}`; /** * The image-dependent half of a Postgres container spec: how the entrypoint is 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 6ae08a68b3..5c18223ff6 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 @@ -533,7 +533,7 @@ describe("slim Postgres image spec", () => { { containerPath: "/etc/postgresql-custom/pgsodium_root.key", content: "custom-root-key" }, { containerPath: "/etc/postgresql.schema.sql", - content: `${LEGACY_START_DB_SCHEMA_SQL}\n${LEGACY_START_DB_WEBHOOK_SQL}\n${LEGACY_START_DB_SUPABASE_SQL}\nALTER ROLE postgres WITH SUPERUSER;\nCREATE EXTENSION IF NOT EXISTS vector;\n`, + content: `${LEGACY_START_DB_SCHEMA_SQL}\n${LEGACY_START_DB_WEBHOOK_SQL}\n${LEGACY_START_DB_SUPABASE_SQL}`, }, ]); }); From 94cd3a42e040436c96c54a6f4914e361d5f1f0eb Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 27 Aug 2026 11:25:16 +0200 Subject: [PATCH 17/24] fix(cli): make the pooler start and fit under slim images Two fixes for `[db.pooler] enabled = true`: - The healthcheck exec'd curl, which the distroless slim supavisor image does not ship, so the container could never turn healthy and `start` failed outright under SUPABASE_USE_SLIM_IMAGES. Probe with the image's /bin/busybox wget instead, the same pattern realtime and analytics use. - Supavisor's internal metadata Ecto pool defaults to 25 connections, half of the slim local-dev max_connections=50. With every service enabled the database saturated at 50/50 and realtime failed with DatabaseLackOfConnections. Cap the meta pool at 5, which is plenty for the single local tenant and also stops wasting 25 of docker.io's 100. Co-Authored-By: Claude Fable 5 --- .../start/services/supavisor.service.ts | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) 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..69d45a414f 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, @@ -159,6 +163,10 @@ export function legacyBuildSupavisorContainerSpec( PROXY_PORT_SESSION: LEGACY_SUPAVISOR_SESSION_PORT, PROXY_PORT_TRANSACTION: LEGACY_SUPAVISOR_TRANSACTION_PORT, DATABASE_URL: `ecto://${input.dbUser}:${input.dbPassword}@${input.dbHost}:${input.dbPort}/_supabase`, + // Supavisor's internal metadata Ecto pool defaults to 25 connections — + // half of the slim image's local-dev max_connections=50. A handful is + // plenty for the single local tenant. + DB_POOL_SIZE: "5", CLUSTER_POSTGRES: "true", SECRET_KEY_BASE: LEGACY_SUPAVISOR_SECRET_KEY_BASE, VAULT_ENC_KEY: LEGACY_SUPAVISOR_ENCRYPTION_KEY, @@ -180,20 +188,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], From 691ff6b4fd33efb0efa687c8880dac751084f0ac Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 27 Aug 2026 11:25:18 +0200 Subject: [PATCH 18/24] fix(cli): keep image transformation on under slim storage, bump storage to v1.71.0 storage-api prefers IMAGE_TRANSFORMATION_ENABLED over the legacy ENABLE_IMAGE_TRANSFORMATION key the CLI sets, and the slim storage image bakes IMAGE_TRANSFORMATION_ENABLED=false as an image ENV default, silently disabling transforms regardless of config.toml. Set the modern key explicitly so the config value wins on both image families. storage-api v1.71.0 also fixes the slim image's /home/nonroot volume-root mode (0700 -> 0711 65532), which blocked imgproxy (uid 999) from traversing the shared storage volume. Verified on a fresh stack: upload, public download, 32x32 render, vector buckets, and TUS create all green with no manual intervention. Co-Authored-By: Claude Fable 5 --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- .../cli/src/legacy/commands/start/services/storage.service.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index c530a20865..13c75dcc78 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -12,7 +12,7 @@ FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.10 AS supavisor FROM supabase/gotrue:v2.196.0 AS gotrue FROM supabase/realtime:v2.129.3 AS realtime -FROM supabase/storage-api:v1.70.3 AS storage +FROM supabase/storage-api:v1.71.0 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 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 3153c30f0b..ba6aa5c616 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.ts @@ -159,6 +159,10 @@ export function legacyBuildStorageEnv(input: LegacyStorageEnvInput): Record Date: Thu, 27 Aug 2026 19:18:37 +0200 Subject: [PATCH 19/24] fix(cli): close slim-stack gaps against the published image contracts Slim-services #280 ships pg_dumpall/uniq, max_connections=100, /mnt owned by uid 65532, and exec-form HEALTHCHECKs. Drop the CLI workarounds those images replace and fix the remaining orchestration: omit the extra gen-types node argv, mount functions off /root, restore the realtime one-shot before user migrations, slim-translate only the current Dockerfile pin, remount storage at /mnt with a family write probe, and remove the unconditional DB_POOL_SIZE=5 cap. --- .../legacy/commands/db/start/SIDE_EFFECTS.md | 53 ++++---- .../download/download.integration.test.ts | 52 ++++++++ .../legacy/commands/gen/types/SIDE_EFFECTS.md | 36 +++--- .../commands/gen/types/types.handler.ts | 9 +- .../gen/types/types.integration.test.ts | 46 +++++++ .../legacy/commands/gen/types/types.shared.ts | 22 +--- .../commands/gen/types/types.unit.test.ts | 21 +++ .../commands/services/services.handler.ts | 1 + .../src/legacy/commands/start/SIDE_EFFECTS.md | 50 ++++---- .../start/services/storage.service.ts | 18 +-- .../services/storage.service.unit.test.ts | 6 +- .../start/services/supavisor.service.ts | 4 - .../legacy/commands/start/start.handler.ts | 30 ++++- .../start/start.services.unit.test.ts | 23 +++- .../db-bootstrap/container-lifecycle.ts | 56 ++++++++ .../container-lifecycle.unit.test.ts | 40 ++++++ .../legacy/shared/db-bootstrap/db-setup.ts | 120 +++++++++--------- .../shared/db-bootstrap/db-setup.unit.test.ts | 11 +- .../shared/db-bootstrap/pinned-image.ts | 31 ++--- .../db-bootstrap/pinned-image.unit.test.ts | 56 +++++--- apps/cli/src/legacy/shared/legacy-db-image.ts | 29 ++--- .../shared/legacy-db-image.unit.test.ts | 23 +++- .../shared/legacy-edge-runtime-image.ts | 31 ++--- .../legacy-edge-runtime-image.unit.test.ts | 31 ++++- apps/cli/src/shared/functions/deploy.ts | 12 +- .../src/shared/functions/deploy.unit.test.ts | 25 +++- apps/cli/src/shared/functions/download.ts | 15 ++- .../src/shared/functions/functions.shared.ts | 10 +- .../functions/functions.shared.unit.test.ts | 14 +- apps/cli/src/shared/functions/serve.ts | 1 + .../src/shared/services/services.shared.ts | 28 +++- .../services/services.shared.unit.test.ts | 20 ++- apps/cli/src/shared/services/slim-images.ts | 50 ++++++++ .../shared/services/slim-images.unit.test.ts | 38 +++++- 34 files changed, 736 insertions(+), 276 deletions(-) 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 34cbf9e502..17b098db6e 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -36,11 +36,10 @@ composition reuses too — see that command's `SIDE_EFFECTS.md`): heredoc script, `[db.settings]` travels as trailing `-c key=value` argv rather than a `postgresql.conf` append, and `/etc/postgresql.schema.sql` plus the pgsodium root key are `docker cp`'d in before start (the slim image's bundled `migrate.sh` runs that schema file - once, at initdb, exactly like the docker.io image does). Slim schema SQL also re-promotes - `postgres` to SUPERUSER and `CREATE EXTENSION vector` after the image's bundled demote - migration, so storage-api vector-bucket migrations can run as that role. `--from-backup` combined with a + once, at initdb, exactly like the docker.io image does). `--from-backup` combined with a resolved slim image is refused here instead — the restore entrypoint has no slim - equivalent. + equivalent. A historical `.temp/postgres-version` pin stays on docker.io; only the + current Dockerfile pin is slim-translated. 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 @@ -48,7 +47,9 @@ 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; slim Storage's one-shot uses the + docker.io storage image), 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 @@ -116,27 +117,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_USE_SLIM_IMAGES` | resolves every service image from the slim `ghcr.io/supabase/cli/` builds instead of the docker.io Dockerfile pins (`true`/`1` enable); the pg 13/14/15 fallbacks, OrioleDB images, and the pg-delta catalog-warmup container (its script needs an `sh -c` entrypoint the distroless build has no shell for) stay on docker.io | no | -| `SUPABASE_DB_PORT` | overrides `db.port` (the published host port) | no | -| `SUPABASE_DB_MAJOR_VERSION` | overrides `db.major_version` (image selection, schema branch) | no | -| `SUPABASE_DB_HEALTH_TIMEOUT` | overrides `db.health_timeout` | no | -| `SUPABASE_DB_SETTINGS_*` | overrides individual `[db.settings]` fields | no | -| `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` | overrides `experimental.orioledb_version` (image + env) | no | -| `SUPABASE_EXPERIMENTAL_S3_{HOST,REGION,ACCESS_KEY,SECRET_KEY}` | OrioleDB S3 env overrides | no | -| `SUPABASE_REALTIME_ENABLED` | gates the fresh-volume realtime migrate job | no | -| `SUPABASE_REALTIME_IP_VERSION` / `_MAX_HEADER_LENGTH` | realtime migrate job env overrides | no | -| `SUPABASE_STORAGE_ENABLED` | gates the fresh-volume storage migrate job | no | -| `SUPABASE_STORAGE_FILE_SIZE_LIMIT` | storage migrate job env override | no | -| `SUPABASE_AUTH_ENABLED` | gates the fresh-volume auth migrate job | no | -| `SUPABASE_AUTH_EXTERNAL_URL` / `SUPABASE_AUTH_SITE_URL` | auth migrate job env overrides | no | -| `SUPABASE_AUTH_JWT_EXPIRY` | Postgres's `JWT_EXP` env / signing | no | -| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache warmup (unset/unrecognized defaults to the next engine, which skips it) | no | -| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file, installed into the process environment before any Docker work) to pick the Docker daemon this whole command talks to | no | +| 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/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index 04fa4c2b0f..f8a6606d1b 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { vi } from "vitest"; import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; import { existsSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; @@ -626,6 +627,57 @@ describe("legacy functions download", () => { }).pipe(Effect.provide(layer)); }); + it.live("mounts eszip under /tmp and deno cache at HOME on a slim edge-runtime image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliSettings: mockLegacyCliSettings({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + const hostEszipPath = resolve( + tempRoot.current, + "supabase", + ".temp", + "output_hello-world.eszip", + ); + expect(runCommand?.args).toContain(`supabase_edge_runtime_${PROJECT_ID}:/home/nonroot:rw`); + expect(runCommand?.args).toContain( + `${hostEszipPath}:/tmp/eszips/output_hello-world.eszip:ro`, + ); + expect(runCommand?.args.slice(-6)).toEqual([ + dockerfileServiceImage("edgeruntime"), + "unbundle", + "--eszip", + "/tmp/eszips/output_hello-world.eszip", + "--output", + "/home/deno/hello-world", + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }); + it.live("omits the named Deno cache volume bind on Bitbucket", () => { // Established behavior: the named-volume bind is dropped entirely on // Bitbucket rather than just skipping its explicit creation — 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 b753ab500d..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,20 +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_USE_SLIM_IMAGES` | resolves the pg-meta image from the slim `ghcr.io/supabase/cli/pgmeta` build (`true`/`1` enable) | no | -| `SUPABASE_CA_SKIP_VERIFY` | when `true`, prints a TLS-verification-disabled warning to stderr | no | +| 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/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 1ad3c6284c..52a1666e35 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -32,8 +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`; slim refs skip the Realtime and Storage -jobs and run Auth as `migrate`). 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 + @@ -74,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 @@ -120,12 +122,10 @@ to Storage. Realtime and analytics keep a busybox `wget --spider` probe; slim Edge Runtime is started without `--entrypoint sh` (the wrapped binary has no shell) and the main-service template is copied to `/tmp/index.ts` (`--main-service=/tmp`) because the image runs as uid 65532 -and cannot read `/root`. Slim Storage mounts its named volume at `/home/nonroot` -(owned by uid 65532 in the image) instead of `/mnt`, which Docker would create -root-owned. Slim Postgres's `/etc/postgresql.schema.sql` postinit also -runs `ALTER ROLE postgres WITH SUPERUSER` plus `CREATE EXTENSION vector` as -`supabase_admin` after bundled migrations demote `postgres` — storage-api's -vector-bucket migrations create that extension as the `postgres` role. +and cannot read `/root`. Slim Storage mounts its named volume at `/mnt` (owned by +uid 65532 in the image). An existing docker.io storage volume is write-probed as +uid 65532 before create; a family mismatch fails with `stop --no-backup` or unset +the flag (same remediation as the slim Postgres volume 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 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 ba6aa5c616..cc7da39f8f 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.ts @@ -48,14 +48,6 @@ import { /** Both the container's `FILE_STORAGE_BACKEND_PATH` and its named-volume mount target. */ const LEGACY_STORAGE_DOCKER_PATH = "/mnt"; -/** - * Distroless slim storage runs as uid 65532. `/mnt` does not exist in the - * image, so a named volume mounted there is created root-owned and - * `mkdir /mnt/stub` fails with EACCES. `/home/nonroot` is owned by that user; - * Docker copies the image directory into an empty named volume, so the - * tenant dir is writable. - */ -const LEGACY_STORAGE_SLIM_DOCKER_PATH = "/home/nonroot"; export interface LegacyStorageVectorEnvInput { /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ @@ -209,16 +201,15 @@ export interface LegacyStorageContainerSpecInput { /** * Builds the `docker create` spec for the Storage container. `binds` mounts - * the container's own named volume at `/mnt` (docker.io) or `/home/nonroot` - * (slim, uid 65532) — no `ports`/`exposedPorts`, Storage is reached only via - * its Docker network alias. + * the container's own named volume at `/mnt` — no `ports`/`exposedPorts`, + * Storage is reached only via its Docker network alias. Slim `/mnt` is owned + * by uid 65532 so an empty volume inherits that owner. */ export function legacyBuildStorageContainerSpec( input: LegacyStorageContainerSpecInput, ): LegacyStartContainerSpec { const containerName = legacyServiceContainerName("storage", input.projectId); const slim = legacyUsesSlimRuntime(input.image); - const storagePath = slim ? LEGACY_STORAGE_SLIM_DOCKER_PATH : LEGACY_STORAGE_DOCKER_PATH; const env = { ...legacyBuildStorageEnv({ targetMigration: input.targetMigration, @@ -238,14 +229,13 @@ export function legacyBuildStorageContainerSpec( vectorBucketsEnabled: input.vectorBucketsEnabled, projectEnvValues: input.projectEnvValues, }), - FILE_STORAGE_BACKEND_PATH: storagePath, }; return { image: input.image, containerName, env, - binds: [`${containerName}:${storagePath}`], + binds: [`${containerName}:${LEGACY_STORAGE_DOCKER_PATH}`], // Distroless slim storage has no /bin/sh (nor wget) and Docker CLI // healthchecks are always CMD-SHELL, so there is no probe to declare here. // `start` gates readiness on Kong's `/storage/v1/status` instead — see diff --git a/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts index 7c96760858..1b94a2d769 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts @@ -267,13 +267,13 @@ describe("legacyBuildStorageContainerSpec", () => { expect(spec.healthcheck).toBeUndefined(); }); - test("mounts the named volume at /home/nonroot on a slim image so uid 65532 can mkdir the tenant dir", () => { + test("mounts the named volume at /mnt on a slim image (uid 65532 owns /mnt)", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = legacyBuildStorageContainerSpec({ ...input, image: "ghcr.io/supabase/cli/storage:v1.70.3", }); - expect(spec.binds).toEqual(["supabase_storage_proj:/home/nonroot"]); - expect(spec.env["FILE_STORAGE_BACKEND_PATH"]).toBe("/home/nonroot"); + 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/supavisor.service.ts b/apps/cli/src/legacy/commands/start/services/supavisor.service.ts index 69d45a414f..e2d0a45ac5 100644 --- a/apps/cli/src/legacy/commands/start/services/supavisor.service.ts +++ b/apps/cli/src/legacy/commands/start/services/supavisor.service.ts @@ -163,10 +163,6 @@ export function legacyBuildSupavisorContainerSpec( PROXY_PORT_SESSION: LEGACY_SUPAVISOR_SESSION_PORT, PROXY_PORT_TRANSACTION: LEGACY_SUPAVISOR_TRANSACTION_PORT, DATABASE_URL: `ecto://${input.dbUser}:${input.dbPassword}@${input.dbHost}:${input.dbPort}/_supabase`, - // Supavisor's internal metadata Ecto pool defaults to 25 connections — - // half of the slim image's local-dev max_connections=50. A handful is - // plenty for the single local tenant. - DB_POOL_SIZE: "5", CLUSTER_POSTGRES: "true", SECRET_KEY_BASE: LEGACY_SUPAVISOR_SECRET_KEY_BASE, VAULT_ENC_KEY: LEGACY_SUPAVISOR_ENCRYPTION_KEY, diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 6d802ed365..6f75d7ad41 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -135,10 +135,15 @@ import { legacyRollbackStart, } from "../../shared/db-bootstrap/rollback.ts"; import { legacyResolveDbBootstrapConfig } from "../../shared/db-bootstrap/bootstrap-config.ts"; -import { legacyStartDatabase } from "../../shared/db-bootstrap/start-database.ts"; +import { + LegacySlimImageVolumeInaccessibleError, + legacyStartDatabase, +} from "../../shared/db-bootstrap/start-database.ts"; import { LEGACY_START_SERVICES } from "./start.services.ts"; import { legacyCreateContainer, + legacyIsVolumeWritableByUid, + legacyVolumeExists, type LegacyContainerOpts, } from "../../shared/db-bootstrap/container-lifecycle.ts"; import { legacyEnsureImagesCached } from "../../shared/db-bootstrap/image-prepull.ts"; @@ -1792,9 +1797,30 @@ 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); + if (entry.service === "storage" && legacyUsesSlimRuntime(resolvedServiceImage)) { + const storageVolumeExisted = yield* legacyVolumeExists(spawner, storageContainerName); + if (storageVolumeExisted) { + const writable = yield* legacyIsVolumeWritableByUid( + spawner, + resolveImage(postgresImage), + storageContainerName, + 65532, + ); + if (!writable) { + return yield* Effect.fail( + new LegacySlimImageVolumeInaccessibleError({ + message: + "the existing storage volume was initialized by a non-slim storage image and is not writable by the slim image's user", + suggestion: `Run ${legacyAqua("supabase stop --no-backup")} to reset the local storage volume, or unset SUPABASE_USE_SLIM_IMAGES.`, + }), + ); + } + } + } const { spec, excludeFromHealthWatch } = yield* buildSpecForService( entry.service, - resolveImage(image), + resolvedServiceImage, ).pipe( Effect.catchDefect((defect) => Effect.fail( 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 98ca72146e..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 @@ -2,7 +2,9 @@ import { CliConfigSchema, type CliConfig } from "@supabase/config"; import { Schema } from "effect"; 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 { @@ -12,6 +14,12 @@ import { } 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); @@ -246,17 +254,20 @@ describe("legacyResolveStartImagePlan under SUPABASE_USE_SLIM_IMAGES", () => { it("plans docker.io images while the flag is off", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); - expect(imageFor("gotrue")).toBe("supabase/gotrue:v2.196.0"); - expect(imageFor("vector")).toBe("timberio/vector:0.53.0-alpine"); + expect(imageFor("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("ghcr.io/supabase/cli/auth:v2.196.0"); - expect(imageFor("logflare")).toBe("ghcr.io/supabase/cli/analytics:v1.50.4"); - expect(imageFor("vector")).toBe("ghcr.io/supabase/cli/vector:0.53.0"); - expect(imageFor("supavisor", { pooler: "2.0.0" })).toBe("ghcr.io/supabase/cli/pooler:v2.0.0"); + expect(imageFor("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/shared/db-bootstrap/container-lifecycle.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts index 885608cac8..de7c16e948 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts @@ -550,6 +550,62 @@ export function legacyIsVolumeAccessibleToImage( ); } +/** + * Write probe for an existing named volume as `uid` (slim storage is + * distroless — do not exec in that image). Use an image that has `sh` (the + * already-resolved postgres image, same uid 65532). `/bin/sh` exists on both + * slim and docker.io postgres; `/usr/bin/sh` does not. Exit `0` writable, + * `1` not; other exits fail. + */ +export function legacyIsVolumeWritableByUid( + spawner: Spawner, + probeImage: string, + name: string, + uid: number, +): Effect.Effect { + const fail = (message: string): LegacyContainerCreateError => + new LegacyContainerCreateError({ message, reason: "runtime" }); + return Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli( + spawner, + [ + "run", + "--rm", + "--user", + String(uid), + "--entrypoint", + "/bin/sh", + "-v", + `${name}:/probe`, + probeImage, + "-c", + "test -w /probe", + ], + { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, + ).pipe( + Effect.mapError((cause) => + fail(`failed to probe volume access: ${legacyDescribeContainerCliFailure(cause)}`), + ), + ); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), legacyCollectText(child.stderr)], + { concurrency: "unbounded" }, + ).pipe(Effect.mapError(() => fail("failed to probe volume access"))); + if (exitCode === 0) return true; + if (exitCode === 1) return false; + const message = stderr.trim(); + return yield* Effect.fail( + fail( + message.length > 0 + ? `failed to probe volume access: ${message}` + : `failed to probe volume access: exit ${exitCode}`, + ), + ); + }), + ); +} + /** `docker container rm -f ` (or `docker rm -f`) failed. */ export class LegacyContainerRemoveError extends Data.TaggedError("LegacyContainerRemoveError")<{ readonly message: string; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts index 437b21a4c2..3d65181f7e 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts @@ -20,6 +20,7 @@ import { legacyRemoveContainer, legacyRemoveVolume, legacyCreateContainer, + legacyIsVolumeWritableByUid, legacyVolumeExists, } from "./container-lifecycle.ts"; import type { LegacyStartContainerSpec } from "./docker-create-args.ts"; @@ -895,3 +896,42 @@ describe("legacyCreateContainer with an empty containerName (the shadow database }, ); }); + +describe("legacyIsVolumeWritableByUid", () => { + it.live("probes as the given uid with sh, not the storage image", () => { + const mock = mockSpawner(() => ({ exitCode: 0 })); + return legacyIsVolumeWritableByUid( + mock.spawner, + "ghcr.io/supabase/cli/postgres:17.6.1.166", + "supabase_storage_proj", + 65532, + ).pipe( + Effect.map((writable) => { + expect(writable).toBe(true); + expect(mock.spawned[0]).toEqual([ + "run", + "--rm", + "--user", + "65532", + "--entrypoint", + "/bin/sh", + "-v", + "supabase_storage_proj:/probe", + "ghcr.io/supabase/cli/postgres:17.6.1.166", + "-c", + "test -w /probe", + ]); + }), + ); + }); + + it.live("treats exit 1 as not writable", () => { + const mock = mockSpawner(() => ({ exitCode: 1 })); + return legacyIsVolumeWritableByUid( + mock.spawner, + "ghcr.io/supabase/cli/postgres:17.6.1.166", + "supabase_storage_proj", + 65532, + ).pipe(Effect.map((writable) => expect(writable).toBe(false))); + }); +}); 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 c4c4f8204a..fa9ae83854 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -18,9 +18,10 @@ * run-to-completion container on the SAME Docker network as `db` — Go's * `DockerStart` defaults `NetworkMode` to `utils.NetId` when unset, * `docker.go:379-383`), each gated on its own service's `enabled` flag and none - * of which touch `conn` directly. Slim images skip the Realtime and Storage - * jobs: those images migrate (and Realtime seeds) from their long-running - * entrypoint. Slim Auth still runs a one-shot `migrate` under the baked + * 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 @@ -161,6 +162,7 @@ import { type LegacyVaultSecret, legacyUpsertVaultSecrets, } from "../legacy-vault.ts"; +import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; import { legacyEnsureImagesCached, type LegacyImagePrepullError } from "./image-prepull.ts"; import { legacyResolvePinnedImage } from "./pinned-image.ts"; import { legacyUsesSlimRuntime } from "./slim-runtime.ts"; @@ -806,33 +808,29 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( const dbPassword = legacyStartInternalDbPassword(input.dbUrl); if (input.config.realtime.enabled) { - // Slim realtime's ENTRYPOINT (`tini` + `/app/entry.sh`) already runs - // `/app/bin/migrate`, seeds when `SEED_SELF_HOST=true` (set by - // `legacyBuildRealtimeEnv`), then execs the server. The docker.io one-shot - // `eval` health_check would otherwise start a second BEAM against the - // same DB (and hang if it inherited `entry.sh`). Skip it. - if (!legacyUsesSlimRuntime(input.images.realtime)) { - yield* legacyRunStartMigrateJob(spawner, { - image: input.images.realtime, - networkId: input.networkId, - projectId: input.projectId, - projectEnvValues: input.projectEnvValues, - debug: input.debug, - env: legacyBuildRealtimeEnv({ - ipVersion: input.config.realtime.ip_version, - maxHeaderLength: input.config.realtime.max_header_length, - dbHost, - dbPassword, - jwtSecret: input.jwtSecret, - jwks: input.jwks, - }), - cmd: [ - "/app/bin/realtime", - "eval", - `{:ok, _} = Application.ensure_all_started(:realtime)\n{:ok, _} = Realtime.Tenants.health_check("${LEGACY_REALTIME_TENANT_ID}")`, - ], - }); - } + // 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, + projectId: input.projectId, + projectEnvValues: input.projectEnvValues, + debug: input.debug, + env: legacyBuildRealtimeEnv({ + ipVersion: input.config.realtime.ip_version, + maxHeaderLength: input.config.realtime.max_header_length, + dbHost, + dbPassword, + jwtSecret: input.jwtSecret, + jwks: input.jwks, + }), + cmd: [ + "/app/bin/realtime", + "eval", + `{:ok, _} = Application.ensure_all_started(:realtime)\n{:ok, _} = Realtime.Tenants.health_check("${LEGACY_REALTIME_TENANT_ID}")`, + ], + }); } if (input.config.storage.enabled) { // `legacyStartStorageMigrateEnv` parses `storage.file_size_limit` via @@ -848,37 +846,37 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( // fix already applied to `resolveDbHealthTimeoutSeconds` and the // long-running Storage container's own file-size-limit parsing // (`start.handler.ts`). - // Slim storage has no `dist/scripts/migrate-call.js` (the docker.io one-shot - // cmd). Tenant migrations run when the long-running server boots, and the slim - // postgres image already applies the bundled storage schema at initdb. - if (!legacyUsesSlimRuntime(input.images.storage)) { - const storageEnv = yield* Effect.try({ - try: () => - legacyStartStorageMigrateEnv({ - targetMigration: input.storageTargetMigration, - anonKey: input.anonKey, - serviceRoleKey: input.serviceRoleKey, - jwtSecret: input.jwtSecret, - dbHost, - dbPassword, - fileSizeLimit: input.config.storage.file_size_limit, - }), - catch: (cause) => - new LegacyDbSetupError({ - message: `invalid config for storage: ${errMessage(cause)}`, - reason: "invalid_config", - }), - }); - yield* legacyRunStartMigrateJob(spawner, { - image: input.images.storage, - networkId: input.networkId, - projectId: input.projectId, - projectEnvValues: input.projectEnvValues, - debug: input.debug, - env: storageEnv, - cmd: ["node", "dist/scripts/migrate-call.js"], - }); - } + // Slim storage has no `migrate-call.js`. Run the one-shot on the docker.io + // pin so `legacyGetRegistryImageUrl` can rewrite it — same locked + // exception as the pg-delta shell image. + const storageEnv = yield* Effect.try({ + try: () => + legacyStartStorageMigrateEnv({ + targetMigration: input.storageTargetMigration, + anonKey: input.anonKey, + serviceRoleKey: input.serviceRoleKey, + jwtSecret: input.jwtSecret, + dbHost, + dbPassword, + fileSizeLimit: input.config.storage.file_size_limit, + }), + catch: (cause) => + new LegacyDbSetupError({ + message: `invalid config for storage: ${errMessage(cause)}`, + reason: "invalid_config", + }), + }); + yield* legacyRunStartMigrateJob(spawner, { + image: legacyUsesSlimRuntime(input.images.storage) + ? dockerfileServiceImageRaw("storage") + : input.images.storage, + networkId: input.networkId, + projectId: input.projectId, + projectEnvValues: input.projectEnvValues, + debug: input.debug, + env: storageEnv, + cmd: ["node", "dist/scripts/migrate-call.js"], + }); } if (input.config.auth.enabled) { // Slim auth bakes `/usr/local/bin/auth` as ENTRYPOINT, so `["gotrue", "migrate"]` 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 d0b281e139..6f0a42b60b 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 @@ -9,6 +9,7 @@ import { afterEach, vi } from "vitest"; import { Deferred, Effect, FileSystem, Layer, Path, Schema, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; import { LegacyDbExecError } from "../legacy-db-connection.errors.ts"; import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; @@ -29,6 +30,8 @@ import { type LegacyStartSetupLocalDatabaseInput, } from "./db-setup.ts"; +const currentStorageTag = dockerfileServiceImageRaw("storage").split(":")[1] ?? ""; + const decodeConfig = Schema.decodeUnknownSync(CliConfigSchema); /** @@ -369,7 +372,7 @@ describe("legacyStartSetupLocalDatabase", () => { }); it.effect( - "slim refs: skips realtime and storage one-shot jobs and passes migrate as auth argv", + "slim refs: runs realtime one-shot, storage on docker.io, and auth as migrate", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const workdir = makeWorkdir(); @@ -390,9 +393,13 @@ describe("legacyStartSetupLocalDatabase", () => { ).pipe( Effect.map(() => { expect(docker.runs.map((job) => job.image)).toEqual([ + "ghcr.io/supabase/cli/realtime:v2.129.3", + `public.ecr.aws/supabase/storage-api:${currentStorageTag}`, "ghcr.io/supabase/cli/auth:v2.196.0", ]); - expect(docker.runs[0]?.cmd).toEqual(["migrate"]); + 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(["migrate"]); rmSync(workdir, { recursive: true, force: true }); }), ); 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 c36ae05714..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,10 +1,8 @@ -import { dockerImageForService } from "@supabase/stack/versions"; -import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; -import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; -import { - replaceImageTag, - type LocalServiceVersionName, - 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"; /** @@ -20,21 +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]; - if (pinnedVersion === undefined) { - return baseImage; - } - // A verbatim tag swap would be wrong on slim refs whose tag scheme differs - // from docker.io's (`pooler`/`analytics` pins are unprefixed on docker.io but - // `v`-prefixed under `ghcr.io/supabase/cli`), so let the catalog normalize. - return slimImagesEnabled() - ? dockerImageForService(localServiceName, pinnedVersion) - : replaceImageTag(baseImage, pinnedVersion); + 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 index 54efe8a308..57bf26555b 100644 --- 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 @@ -1,7 +1,17 @@ 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(); }); @@ -9,7 +19,7 @@ afterEach(() => { describe("legacyResolvePinnedImage", () => { it("resolves docker.io images while the slim flag is off", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); - expect(legacyResolvePinnedImage("gotrue", "auth", {})).toBe("supabase/gotrue:v2.196.0"); + expect(legacyResolvePinnedImage("gotrue", "auth", {})).toBe(currentAuth); expect(legacyResolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" })).toBe( "supabase/gotrue:v2.100.0", ); @@ -18,36 +28,52 @@ describe("legacyResolvePinnedImage", () => { ); }); - it("resolves slim images when the flag is on", () => { + 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( - "ghcr.io/supabase/cli/auth:v2.196.0", - ); - expect(legacyResolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" })).toBe( - "ghcr.io/supabase/cli/auth:v2.100.0", + expect(legacyResolvePinnedImage("gotrue", "auth", {})).toBe(toSlimImage("gotrue", currentAuth)); + expect(legacyResolvePinnedImage("gotrue", "auth", { auth: currentAuthTag })).toBe( + toSlimImage("gotrue", currentAuth), ); }); - // A pin written to `supabase/.temp/-version` follows docker.io's tag - // scheme, which is unprefixed for these two while their slim tags are not. - it("normalizes pooler and analytics pins onto the slim tag scheme", () => { + 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( - "ghcr.io/supabase/cli/pooler:v2.0.0", + "supabase/supavisor:2.0.0", ); - expect(legacyResolvePinnedImage("logflare", "analytics", { analytics: "1.4.0" })).toBe( - "ghcr.io/supabase/cli/analytics:v1.4.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 the postgres pin path stable across the flag", () => { + 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( - "ghcr.io/supabase/cli/postgres:17.4.1.1", + "supabase/postgres:17.4.1.1", + ); + expect(legacyResolvePinnedImage("pg", "postgres", { postgres: currentPostgresTag })).toBe( + toSlimImage("pg", currentPostgres), ); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-db-image.ts b/apps/cli/src/legacy/shared/legacy-db-image.ts index 369755780b..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, @@ -13,7 +14,7 @@ import { dockerfileServiceImage } from "../../shared/services/dockerfile-images. // Read per call, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is // observed by the resolver (and by tests that stub the env). -const legacyPgImage = () => dockerfileServiceImage("pg"); +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"; @@ -80,20 +81,9 @@ export const legacyResolveDbImage = Effect.fnUntraced(function* ( ? `supabase/postgres:${orioledbVersion}-orioledb` : `supabase/postgres:orioledb-${orioledbVersion}`; } - let image = legacyPgImage(); - 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( @@ -105,8 +95,13 @@ export const legacyResolveDbImage = Effect.fnUntraced(function* ( const currentTag = colon >= 0 ? image.slice(colon + 1) : image; if (versionCompare(currentTag, "15.1.0.55") >= 0) { 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 190202036a..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 @@ -6,9 +6,16 @@ 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) => { @@ -70,12 +77,22 @@ describe("legacyResolveDbImage", () => { }); }); - it.effect("rewrites the default major's pin to the slim registry", () => { + 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("ghcr.io/supabase/cli/postgres:17.9.9.999"); + 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-edge-runtime-image.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts index 6670386345..043487ac44 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts @@ -4,6 +4,7 @@ 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 @@ -32,31 +33,28 @@ function replaceImageTag(image: string, tag: string): string { } const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( - baseImage: string, fs: FileSystem.FileSystem, path: Path.Path, workdir: string, denoVersion: number, + slim: boolean, ) { - let image = baseImage; + 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) { - // A pin of the deno1 tag (e.g. left in .temp by an earlier deno_version = 1 - // run) resolves docker.io whatever selected it: no slim build of that tag - // exists, so tag-swapping it onto a slim base would yield an unpullable ref. - image = - pinned === DENO1_EDGE_RUNTIME_VERSION - ? LEGACY_EDGE_RUNTIME_DENO1_IMAGE - : replaceImageTag(baseImage, pinned); + if (pinned === DENO1_EDGE_RUNTIME_VERSION) { + return LEGACY_EDGE_RUNTIME_DENO1_IMAGE; } - if (denoVersion === 1) { - image = LEGACY_EDGE_RUNTIME_DENO1_IMAGE; + if (!slim) { + return pinned.length > 0 ? replaceImageTag(raw, pinned) : raw; } - return image; + return slimImageForCurrentPin("edgeruntime", raw, pinned.length > 0 ? pinned : undefined); }); /** @@ -64,14 +62,14 @@ const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( * `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). + * 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(legacyEdgeRuntimeImage(), fs, path, workdir, denoVersion); +) => resolveEdgeRuntimeImage(fs, path, workdir, denoVersion, true); /** * Same resolution pinned to docker.io, for callers that replace the image @@ -84,5 +82,4 @@ export const legacyResolveEdgeRuntimeShellImage = ( path: Path.Path, workdir: string, denoVersion: number, -) => - resolveEdgeRuntimeImage(dockerfileServiceImageRaw("edgeruntime"), fs, path, workdir, denoVersion); +) => resolveEdgeRuntimeImage(fs, path, workdir, denoVersion, false); 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 0d93470c37..f34d73f902 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 @@ -6,12 +6,19 @@ 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 { + 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* () { const fs = yield* FileSystem.FileSystem; @@ -82,7 +89,7 @@ describe("legacyResolveEdgeRuntimeImage", () => { vi.unstubAllEnvs(); }); - it.effect("resolves a normal pin onto the slim base", () => { + 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 }); @@ -90,7 +97,25 @@ describe("legacyResolveEdgeRuntimeImage", () => { return resolve(dir, 2).pipe( Effect.tap((image) => Effect.sync(() => { - expect(image).toBe("ghcr.io/supabase/cli/edge-runtime:v9.9.9"); + 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 }); }), ), diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 1cf21cd163..7688970766 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -27,6 +27,7 @@ import { hasExplicitLongFlag, lastExplicitLongFlagValue, } from "../cli/cobra-flag-groups.ts"; +import { usesSlimImageRuntime } from "../services/slim-images.ts"; import { edgeRuntimeImage, FUNCTIONS_BUNDLER_MUTEX_GROUP, @@ -1200,6 +1201,8 @@ export async function buildDockerBinds( readonly additionalModuleRoots?: ReadonlyArray; readonly onWarning?: (message: string) => Promise; readonly skipMissingImportMapTargets?: boolean; + /** Resolved edge-runtime ref — slim images persist cache under `/home/nonroot`. */ + readonly image?: string; } = {}, ): Promise> { const hostFunctionsDir = resolve(functionsDir); @@ -1233,7 +1236,10 @@ export async function buildDockerBinds( if (process.env["BITBUCKET_CLONE_DIR"] === undefined) { binds.unshift({ hostPath: localDockerId("edge_runtime", projectId), - containerPath: "/root/.cache/deno", + containerPath: + options.image !== undefined && usesSlimImageRuntime(options.image) + ? "/home/nonroot" + : "/root/.cache/deno", mode: "rw", externalScope: false, }); @@ -1423,9 +1429,11 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( }); } const outputPath = join(outputDir, "output.eszip"); + const rawImage = edgeRuntimeImage(edgeRuntimeVersion); const binds = yield* Effect.promise(() => buildDockerBinds(projectId, functionsDir, outputDir, config, { onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), + image: rawImage, }), ); // Go: `DockerStart` -> `DockerResolveImageIfNotCached` (`internal/utils/docker.go:326-386`) @@ -1439,7 +1447,7 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( // `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), + rawImage, projectEnvValues, ); yield* ensureDockerNetwork(networkMode, projectId); diff --git a/apps/cli/src/shared/functions/deploy.unit.test.ts b/apps/cli/src/shared/functions/deploy.unit.test.ts index 7eb7be3ffb..aaa019c4c1 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,26 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil } }); }); + +describe("buildDockerBinds — slim deno cache path", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("mounts the named cache volume at /home/nonroot on a slim image", 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, { + image: "ghcr.io/supabase/cli/edge-runtime:v1.74.3", + }); + expect(binds.some((bind) => bind.containerPath === "/home/nonroot")).toBe(true); + expect(binds.some((bind) => bind.containerPath === "/root/.cache/deno")).toBe(false); + } 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..7336675aaf 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -28,6 +28,7 @@ import { runChildProcess, } from "./functions-docker.ts"; import { loadFunctionsCliConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; +import { usesSlimImageRuntime } from "../services/slim-images.ts"; import { edgeRuntimeImage, FUNCTIONS_BUNDLER_MUTEX_GROUP, @@ -48,7 +49,13 @@ 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"; +const slimEszipDir = "/tmp/eszips"; +const dockerIoDenoCacheDir = "/root/.cache/deno"; +// Slim HOME `/home/nonroot` is 0700 uid 65532. A named volume inherits that +// owner only when the mount path already exists; `/tmp/.cache/deno` does not, +// so Deno's default `$HOME/.cache/deno` never used a /tmp bind. +const slimDenoCacheDir = "/home/nonroot"; export interface DownloadFunctionsOptions { readonly functionName: Option.Option; @@ -1065,8 +1072,10 @@ 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 slim = usesSlimImageRuntime(image); + const dockerEszipPath = posix.join(slim ? slimEszipDir : dockerIoEszipDir, eszipFileName); const dockerOutputPath = posix.join(DOCKER_DENO_DIR, slug); + const dockerDenoCacheDir = slim ? slimDenoCacheDir : dockerIoDenoCacheDir; // Go: `viper.GetString("network-id")` else `NetId` (`docker.go:379-383`) — // `--network-id` is a persistent root flag (`cmd/root.go:328`), not @@ -1104,7 +1113,7 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( // `buildDockerBinds`. const binds = [ ...(process.env["BITBUCKET_CLONE_DIR"] === undefined - ? [`${localDockerId("edge_runtime", projectId)}:/root/.cache/deno:rw`] + ? [`${localDockerId("edge_runtime", projectId)}:${dockerDenoCacheDir}:rw`] : []), `${hostEszipPath}:${dockerEszipPath}:ro`, `${functionsDir}:${DOCKER_DENO_DIR}:rw`, diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 76f8e9cca1..553b8bd94f 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -1,7 +1,11 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { Effect } from "effect"; -import { dockerfileServiceImage } from "../services/dockerfile-images.ts"; +import { + dockerfileServiceImage, + dockerfileServiceImageRaw, +} from "../services/dockerfile-images.ts"; +import { slimImageForCurrentPin } from "../services/slim-images.ts"; const functionSlugPattern = /^[A-Za-z][A-Za-z0-9_-]*$/; @@ -65,9 +69,7 @@ export function edgeRuntimeImage(tag: string): string { if (tag === DENO1_EDGE_RUNTIME_VERSION) { return `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`; } - const base = defaultEdgeRuntimeImage(); - const index = base.indexOf(":"); - return base.slice(0, index + 1) + tag.trim(); + return slimImageForCurrentPin("edgeruntime", dockerfileServiceImageRaw("edgeruntime"), tag); } /** diff --git a/apps/cli/src/shared/functions/functions.shared.unit.test.ts b/apps/cli/src/shared/functions/functions.shared.unit.test.ts index 9b16ed2c03..1ef666caf7 100644 --- a/apps/cli/src/shared/functions/functions.shared.unit.test.ts +++ b/apps/cli/src/shared/functions/functions.shared.unit.test.ts @@ -1,7 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { dockerfileServiceImageRaw } from "../services/dockerfile-images.ts"; import { DENO1_EDGE_RUNTIME_VERSION, edgeRuntimeImage } from "./functions.shared.ts"; +const currentEdgeRuntimeTag = dockerfileServiceImageRaw("edgeruntime").split(":")[1] ?? ""; + afterEach(() => { vi.unstubAllEnvs(); }); @@ -14,9 +17,16 @@ describe("edgeRuntimeImage", () => { ); }); - it("rewrites a non-deno1 tag onto the slim ghcr.io image when the flag is on", () => { + 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.74.3")).toBe("ghcr.io/supabase/cli/edge-runtime:v1.74.3"); + 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", () => { diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 2b3b79a24a..0b557813d3 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -1667,6 +1667,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo buildDockerBinds(projectId, functionsDir, functionsDir, config, { additionalModuleRoots: [input.flagCwd], skipMissingImportMapTargets: true, + image: input.image, onWarning: async (message) => { bindWarnings.push(message); }, diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 0b0e42be63..85547c6b55 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -1,6 +1,5 @@ import { styleText } from "node:util"; import { makeApiClient, type ApiClient } from "@supabase/api/effect"; -import { dockerImageForService } from "@supabase/stack/versions"; import { Data, Duration, Effect, Exit, Redacted } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -15,7 +14,7 @@ import { parseDockerfileServiceImages, type DockerfileImageSpec, } from "./dockerfile-images.ts"; -import { slimImageForAlias, slimImagesEnabled } from "./slim-images.ts"; +import { slimImageForAlias, slimImageForCurrentPin, slimImagesEnabled } from "./slim-images.ts"; export { parseDockerfileServiceImages } from "./dockerfile-images.ts"; @@ -40,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`). @@ -124,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; @@ -157,9 +162,20 @@ function localServiceImagesForOptions( return baseImage === service.image ? service : { ...service, image: baseImage }; } if (override === undefined && slim) { - // The catalog owns the slim tag scheme, which differs from docker.io's for - // `pooler`/`analytics`; a verbatim tag swap would miss the `v` prefix. - return { ...service, image: dockerImageForService(service.localService, version) }; + return { + ...service, + image: options.slimCurrentPinOnly + ? slimImageForCurrentPin(service.alias, service.image, version) + : slimImageForAlias( + service.alias, + replaceImageTag( + service.image, + normalizeVersionTags + ? tagForServiceVersion(service.localService, version) + : version, + ), + ), + }; } return { ...service, 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 480647eb78..14c43d8ead 100644 --- a/apps/cli/src/shared/services/services.shared.unit.test.ts +++ b/apps/cli/src/shared/services/services.shared.unit.test.ts @@ -88,18 +88,30 @@ describe("services shared", () => { ]); }); - test("applies pins on the slim tag scheme when SUPABASE_USE_SLIM_IMAGES is set", () => { + test("keeps historical pins on docker.io when slimCurrentPinOnly is set", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); expect( - listLocalServiceVersions({ serviceVersions: { pooler: "2.0.0", analytics: "1.4.0" } }), + listLocalServiceVersions({ + slimCurrentPinOnly: true, + serviceVersions: { pooler: "2.0.0", analytics: "1.4.0" }, + }), ).toEqual( expect.arrayContaining([ - { name: "ghcr.io/supabase/cli/pooler", local: "v2.0.0", remote: "" }, - { name: "ghcr.io/supabase/cli/analytics", local: "v1.4.0", remote: "" }, + { 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", () => { diff --git a/apps/cli/src/shared/services/slim-images.ts b/apps/cli/src/shared/services/slim-images.ts index fca55caed3..6698dee926 100644 --- a/apps/cli/src/shared/services/slim-images.ts +++ b/apps/cli/src/shared/services/slim-images.ts @@ -66,6 +66,56 @@ export function slimImageForAlias(alias: string, image: string): string { return slimImagesEnabled() ? toSlimImage(alias, image) : image; } +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); diff --git a/apps/cli/src/shared/services/slim-images.unit.test.ts b/apps/cli/src/shared/services/slim-images.unit.test.ts index f898b41b28..a8b868bc34 100644 --- a/apps/cli/src/shared/services/slim-images.unit.test.ts +++ b/apps/cli/src/shared/services/slim-images.unit.test.ts @@ -1,8 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { dockerfileServiceImages } from "./dockerfile-images.ts"; +import { dockerfileServiceImageRaw, dockerfileServiceImages } from "./dockerfile-images.ts"; import { + pinMatchesCurrentImage, slimImageForAlias, + slimImageForCurrentPin, slimImagesEnabled, toSlimImage, usesSlimImageRuntime, @@ -133,3 +135,37 @@ describe("usesSlimImageRuntime", () => { 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", + ); + }); +}); From c0b20cd2a396350da6b7e7cb672c5f66b3d2c5ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 19:44:05 +0000 Subject: [PATCH 20/24] fix(cli): make functions docker paths work with slim edge-runtime images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slim edge-runtime image runs as uid 65532 (distroless nonroot), which breaks two docker.io-root assumptions in the functions Docker paths on native Linux / WSL2: - functions download --use-docker extracted straight into the host supabase/functions bind, which uid 65532 cannot write when the host directory belongs to a different uid (EACCES). The slim flow now keeps the unbundle output container-local (/tmp/unbundle/, eszip bound read-only under /tmp/eszips) via a create -> start --attach -> cp -> rm --force lifecycle, and the CLI writes the copied files host-side as the invoking user, with the same path-containment checks the multipart downloader applies. - The shared supabase_edge_runtime_ Deno-cache volume mounts at /root/.cache/deno, which the slim runtime neither reaches (0700 /root) nor uses (Deno caches under $HOME=/home/nonroot), and a docker.io-seeded volume is root-owned and can never be made writable by uid 65532 by remounting it. Slim runs (download/deploy/serve/start) now use their own supabase_edge_runtime_slim_ volume mounted over /home/nonroot, which Docker seeds from the image's 65532-owned home — same pattern as the slim Storage volume. Both volume families carry the project label that supabase stop --no-backup prunes by. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DrSmrBgbWfva1KbYWGMV4c --- .../commands/functions/deploy/SIDE_EFFECTS.md | 7 +- .../functions/download/SIDE_EFFECTS.md | 17 +- .../download/download.integration.test.ts | 304 +++++++++++++++--- .../commands/functions/serve/SIDE_EFFECTS.md | 5 +- .../edge-runtime.service.integration.test.ts | 11 + apps/cli/src/shared/functions/deploy.ts | 36 ++- .../src/shared/functions/deploy.unit.test.ts | 30 +- apps/cli/src/shared/functions/download.ts | 271 +++++++++++++--- .../src/shared/functions/functions-docker.ts | 127 +++++++- .../functions/functions-docker.unit.test.ts | 88 +++++ apps/cli/src/shared/functions/serve.ts | 8 +- 11 files changed, 766 insertions(+), 138 deletions(-) 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 8ac96cd816..163b3aa307 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,12 @@ | `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`). Under `SUPABASE_USE_SLIM_IMAGES` it uses the slim-only +`supabase_edge_runtime_slim_` volume instead, mounted over the slim +image's `/home/nonroot` home so uid 65532's `$HOME`-relative deno/npm caches +persist — a docker.io-seeded volume is root-owned and unwritable by that uid, +so the two families never share a name. ## API Routes 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 b2f4e0cc95..5a713bc879 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,15 @@ ## 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`), or the slim-only `supabase_edge_runtime_slim_` (mounted over `/home/nonroot`) under `SUPABASE_USE_SLIM_IMAGES` — a docker.io-seeded volume is root-owned and unwritable by the slim image's uid 65532, so the families never share a name | +| `docker run --rm ... --label com.supabase.cli.project= --label com.docker.compose.project= unbundle --eszip ... --output ...` | Docker-unbundle path, docker.io image | extract the downloaded eszip into `supabase/functions//...`; labeled so orphaned containers can be associated with the project | +| `docker create --name supabase_unbundle__ ...` / `docker start --attach` / `docker cp :/tmp/unbundle/ -` / `docker rm --force` | Docker-unbundle path, slim image (`SUPABASE_USE_SLIM_IMAGES`) | the slim image runs as uid 65532 and cannot write into a host `supabase/functions` bind, so the extraction stays container-local (`/tmp/unbundle/`; eszip bound read-only at `/tmp/eszips/`) and is copied out as a tar stream, written into `supabase/functions//...` by the CLI process as the invoking user; the container is force-removed on every path out, matching `--rm` | +| `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 diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index f8a6606d1b..4f37456c33 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { vi } from "vitest"; +import { afterEach, vi } from "vitest"; import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; +import { containerArchiveBytes } from "../../../../shared/functions/functions-docker.ts"; import { existsSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; @@ -627,57 +628,6 @@ describe("legacy functions download", () => { }).pipe(Effect.provide(layer)); }); - it.live("mounts eszip under /tmp and deno cache at HOME on a slim edge-runtime image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const out = mockOutput({ format: "text" }); - const api = mockLegacyPlatformApi(); - const proxy = mockProxy(); - const child = mockChildProcessSpawner({ exitCode: 0 }); - const layer = Layer.mergeAll( - buildLegacyTestRuntime({ - out, - api, - cliSettings: mockLegacyCliSettings({ workdir: tempRoot.current }), - }), - proxy.layer, - child.layer, - Stdio.layerTest({ - args: Effect.succeed([ - "functions", - "download", - "hello-world", - "--use-docker", - "--project-ref", - PROJECT_ID, - ]), - }), - ); - - return Effect.gen(function* () { - yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); - - const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); - const hostEszipPath = resolve( - tempRoot.current, - "supabase", - ".temp", - "output_hello-world.eszip", - ); - expect(runCommand?.args).toContain(`supabase_edge_runtime_${PROJECT_ID}:/home/nonroot:rw`); - expect(runCommand?.args).toContain( - `${hostEszipPath}:/tmp/eszips/output_hello-world.eszip:ro`, - ); - expect(runCommand?.args.slice(-6)).toEqual([ - dockerfileServiceImage("edgeruntime"), - "unbundle", - "--eszip", - "/tmp/eszips/output_hello-world.eszip", - "--output", - "/home/deno/hello-world", - ]); - }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); - }); - it.live("omits the named Deno cache volume bind on Bitbucket", () => { // Established behavior: the named-volume bind is dropped entirely on // Bitbucket rather than just skipping its explicit creation — @@ -2351,3 +2301,253 @@ describe("legacy functions download", () => { }); }); }); + +/** + * Spawner for the slim (uid 65532) docker-unbundle lifecycle: `create` → + * `start --attach` → `cp :… -` → `rm --force`. Every step exits 0 + * unless overridden; the copy-out step's stdout is the RAW tar bytes of + * `copyOutArchive` — the shared `mockChildProcessSpawner` can't model that + * (it emits newline-terminated text lines, which corrupts a tar stream). + */ +function mockSlimDockerLifecycle( + opts: { + /** Read at spawn time, so a test can fill it inside its own Effect. */ + readonly copyOut?: { archive?: Uint8Array }; + readonly startExitCode?: number; + readonly startStderr?: ReadonlyArray; + } = {}, +) { + const spawned: Array<{ command: string; args: ReadonlyArray }> = []; + const encoder = new TextEncoder(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + const isStart = args[0] === "start"; + const isCopyOut = args[0] === "cp" && args[2] === "-"; + const exitCode = isStart ? (opts.startExitCode ?? 0) : 0; + const copyOutArchive = opts.copyOut?.archive; + const stdout = + isCopyOut && copyOutArchive !== undefined ? Stream.make(copyOutArchive) : Stream.empty; + const stderr = + isStart && opts.startStderr !== undefined + ? Stream.fromIterable(opts.startStderr.map((line) => encoder.encode(`${line}\n`))) + : Stream.empty; + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(exitCode)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1000 + spawned.length), + stdout, + stderr, + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + get spawned() { + return spawned; + }, + layer: Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + }; +} + +describe("legacy functions download — slim images (SUPABASE_USE_SLIM_IMAGES)", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + function buildSlimLayer(child: { layer: Layer.Layer }) { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliSettings: mockLegacyCliSettings({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + return { out, layer }; + } + + it.live( + "stages the unbundle in the container and copies the files out host-side instead of binding the host functions directory", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + // uid 65532 cannot write into a host-owned `supabase/functions` bind on + // native Linux, so the slim flow must never mount it — the extracted + // files arrive via `docker cp` and are written by the CLI process. + const copyOut: { archive?: Uint8Array } = {}; + const child = mockSlimDockerLifecycle({ copyOut }); + const { layer } = buildSlimLayer(child); + + return Effect.gen(function* () { + copyOut.archive = yield* Effect.promise(() => + containerArchiveBytes({ + "hello-world/index.ts": "console.log('from slim unbundle')\n", + "hello-world/nested/util.ts": "export const util = 1;\n", + }), + ); + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + // The slim flow never uses `docker run` — the container must survive + // its own exit for `docker cp` to read the extracted files. + expect(child.spawned.some((spawned) => spawned.args[0] === "run")).toBe(false); + + // Slim runs get their OWN cache volume: a docker.io-seeded + // `supabase_edge_runtime_` volume is root-owned and unwritable by + // uid 65532, so the two families never share a name. + expect(child.spawned.find((spawned) => spawned.args[0] === "volume")).toEqual({ + command: "docker", + args: [ + "volume", + "create", + "--label", + `com.supabase.cli.project=${PROJECT_ID}`, + "--label", + `com.docker.compose.project=${PROJECT_ID}`, + `supabase_edge_runtime_slim_${PROJECT_ID}`, + ], + }); + + const createCommand = child.spawned.find((spawned) => spawned.args[0] === "create"); + expect(createCommand).toBeDefined(); + const containerName = createCommand!.args[2]!; + expect(containerName).toMatch(/^supabase_unbundle_hello-world_[0-9a-f]{8}$/); + + const hostEszipPath = resolve( + tempRoot.current, + "supabase", + ".temp", + "output_hello-world.eszip", + ); + const functionsDir = resolve(tempRoot.current, "supabase", "functions"); + // Cache volume over the nonroot home; eszip bind under /tmp (readable + // by uid 65532 on every slim tag); NO host functions bind. + expect(createCommand!.args).toContain( + `supabase_edge_runtime_slim_${PROJECT_ID}:/home/nonroot:rw`, + ); + expect(createCommand!.args).toContain( + `${hostEszipPath}:/tmp/eszips/output_hello-world.eszip:ro`, + ); + expect(createCommand!.args).not.toContain(`${functionsDir}:/home/deno:rw`); + expect(createCommand!.args.slice(-6)).toEqual([ + // Slim refs skip registry-candidate rewriting, so the resolved image + // is the ghcr.io/supabase/cli reference itself. + dockerfileServiceImage("edgeruntime"), + "unbundle", + "--eszip", + "/tmp/eszips/output_hello-world.eszip", + "--output", + "/tmp/unbundle/hello-world", + ]); + + expect(child.spawned).toContainEqual({ + command: "docker", + args: ["start", "--attach", containerName], + }); + expect(child.spawned).toContainEqual({ + command: "docker", + args: ["cp", `${containerName}:/tmp/unbundle/hello-world`, "-"], + }); + expect(child.spawned).toContainEqual({ + command: "docker", + args: ["rm", "--force", containerName], + }); + + // The extracted files were written host-side, by the CLI process. + expect( + yield* Effect.tryPromise(() => + readFile(join(functionsDir, "hello-world", "index.ts"), "utf8"), + ), + ).toBe("console.log('from slim unbundle')\n"); + expect( + yield* Effect.tryPromise(() => + readFile(join(functionsDir, "hello-world", "nested", "util.ts"), "utf8"), + ), + ).toBe("export const util = 1;\n"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "still removes the staged container and carries the legacy-bundle suggestion when the unbundle fails", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const child = mockSlimDockerLifecycle({ startExitCode: 1, startStderr: ["boom"] }); + const { layer } = buildSlimLayer(child); + + return Effect.gen(function* () { + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("error running container: exit 1"); + expect((error as Error & { suggestion?: string }).suggestion).toBe( + "\nIf your function is deployed using CLI < 1.120.0, trying running supabase functions download --legacy-bundle hello-world instead.", + ); + + // The failure path must not leak a stopped container — the `rm` here + // is the slim flow's replacement for `docker run --rm`. + const createCommand = child.spawned.find((spawned) => spawned.args[0] === "create"); + const containerName = createCommand!.args[2]!; + expect(child.spawned).toContainEqual({ + command: "docker", + args: ["rm", "--force", containerName], + }); + // No copy is attempted for a failed unbundle. + expect(child.spawned.some((spawned) => spawned.args[0] === "cp")).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("refuses to extract an archive entry that escapes the functions directory", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + // Entry paths originate from an `unbundle` run over a remote eszip, so a + // crafted path traversing above `supabase/functions` must be rejected + // before anything is written. + const copyOut: { archive?: Uint8Array } = {}; + const child = mockSlimDockerLifecycle({ copyOut }); + const { layer } = buildSlimLayer(child); + + return Effect.gen(function* () { + copyOut.archive = yield* Effect.promise(() => + containerArchiveBytes({ + "hello-world/../../evil.ts": "nope\n", + }), + ); + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("refusing to extract Function file outside"); + expect(existsSync(join(tempRoot.current, "supabase", "evil.ts"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); +}); 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 0b4d7c18f8..ac02168f52 100644 --- a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md @@ -107,7 +107,10 @@ 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`), or + `supabase_edge_runtime_slim_` (mounted over `/home/nonroot`) under + `SUPABASE_USE_SLIM_IMAGES` — a docker.io-seeded volume is root-owned and unwritable + by the slim image's uid 65532, so the two families never share a name - 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/start/services/edge-runtime.service.integration.test.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts index 2b95632647..bcb51a632c 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 @@ -374,6 +374,17 @@ describe("legacyStartEdgeRuntimeContainer", () => { expect(createArgs).toContain("--main-service=/tmp"); expect(createArgs).not.toContain("--main-service=/root"); + // Slim runs ensure their own Deno-cache volume (mounted over the + // nonroot home by the per-function binds): the shared docker.io + // volume mounts at /root/.cache/deno, which uid 65532 neither reaches + // nor uses (Deno caches under $HOME), and a docker.io-seeded volume + // is root-owned anyway. This input serves no functions, so no cache + // bind appears at all — the per-function bind itself is covered by + // `buildDockerBinds`' own slim test (`deploy.unit.test.ts`). + const volumeCreate = mock.calls.find((call) => call.args[0] === "volume"); + expect(volumeCreate?.args.at(-1)).toBe("supabase_edge_runtime_slim_proj"); + expect(createArgs).not.toContain("supabase_edge_runtime_proj:/root/.cache/deno:rw"); + const cp = mock.calls.find((call) => call.args[0] === "cp"); expect(cp?.args).toEqual(["cp", "-", "supabase_edge_runtime_proj:/"]); const stdin = cp?.stdin; diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 7688970766..4022bfac8c 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -43,10 +43,10 @@ import { } from "./deploy.errors.ts"; import { buildFunctionsDockerRunArgs, + edgeRuntimeCacheVolume, ensureDockerNamedVolume, ensureDockerNetwork, isDockerRunning, - localDockerId, resolveDockerNetworkMode, resolveEdgeRuntimeVersion, resolveFunctionsDockerImage, @@ -1201,7 +1201,11 @@ export async function buildDockerBinds( readonly additionalModuleRoots?: ReadonlyArray; readonly onWarning?: (message: string) => Promise; readonly skipMissingImportMapTargets?: boolean; - /** Resolved edge-runtime ref — slim images persist cache under `/home/nonroot`. */ + /** + * Resolved edge-runtime ref — a slim (uid 65532) ref selects the + * slim-only Deno-cache volume (`edgeRuntimeCacheVolume`) instead of the + * root-owned docker.io one. + */ readonly image?: string; } = {}, ): Promise> { @@ -1234,12 +1238,13 @@ export async function buildDockerBinds( }, ]; if (process.env["BITBUCKET_CLONE_DIR"] === undefined) { + const cacheVolume = edgeRuntimeCacheVolume( + projectId, + options.image !== undefined && usesSlimImageRuntime(options.image), + ); binds.unshift({ - hostPath: localDockerId("edge_runtime", projectId), - containerPath: - options.image !== undefined && usesSlimImageRuntime(options.image) - ? "/home/nonroot" - : "/root/.cache/deno", + hostPath: cacheVolume.name, + containerPath: cacheVolume.containerPath, mode: "rw", externalScope: false, }); @@ -1429,7 +1434,14 @@ 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`). The + // slim gate reads the raw reference: registry-candidate mapping never + // moves an image into or out of the slim `ghcr.io/supabase/cli/` + // namespace, so raw and pull-resolved refs agree on slim-ness. const rawImage = edgeRuntimeImage(edgeRuntimeVersion); + const slim = usesSlimImageRuntime(rawImage); const binds = yield* Effect.promise(() => buildDockerBinds(projectId, functionsDir, outputDir, config, { onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), @@ -1443,15 +1455,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`). - rawImage, - projectEnvValues, - ); + const image = yield* resolveFunctionsDockerImage(rawImage, projectEnvValues); yield* ensureDockerNetwork(networkMode, projectId); - yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId); + yield* ensureDockerNamedVolume(edgeRuntimeCacheVolume(projectId, slim).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 aaa019c4c1..06a21da233 100644 --- a/apps/cli/src/shared/functions/deploy.unit.test.ts +++ b/apps/cli/src/shared/functions/deploy.unit.test.ts @@ -646,23 +646,45 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil }); }); -describe("buildDockerBinds — slim deno cache path", () => { +describe("buildDockerBinds — edge-runtime Deno-cache volume selection", () => { afterEach(() => { vi.unstubAllEnvs(); }); - it("mounts the named cache volume at /home/nonroot on a slim image", async () => { + 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 slim-only volume over /home/nonroot when the image is slim", async () => { + // The slim (uid 65532) edge-runtime resolves Deno's cache under its own + // $HOME, and a docker.io-seeded volume is root-owned — so slim runs get a + // separate volume mounted over the nonroot home (see + // `edgeRuntimeCacheVolume`). 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, { image: "ghcr.io/supabase/cli/edge-runtime:v1.74.3", }); - expect(binds.some((bind) => bind.containerPath === "/home/nonroot")).toBe(true); - expect(binds.some((bind) => bind.containerPath === "/root/.cache/deno")).toBe(false); + const formatted = binds.map(formatDockerBind); + expect(formatted).toContain("supabase_edge_runtime_slim_test-project:/home/nonroot:rw"); + expect(formatted).not.toContain("supabase_edge_runtime_test-project:/root/.cache/deno: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 7336675aaf..64de82eb64 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -1,6 +1,6 @@ import { operationDefinitions, SupabaseApiInputError, type ApiClient } from "@supabase/api/effect"; import { randomUUID } from "node:crypto"; -import { mkdir, open, rename, rm, writeFile } from "node:fs/promises"; +import { mkdir, open, realpath, rename, rm, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { Effect, FileSystem, Option } from "effect"; @@ -17,18 +17,21 @@ import { import { legacyDescribeContainerCliFailure } from "../../legacy/shared/legacy-container-cli.ts"; import { legacyViperEnvStringWithProjectFallback } from "../legacy/legacy-viper-env.ts"; import { + buildFunctionsDockerCreateArgs, buildFunctionsDockerRunArgs, + containerArchiveFiles, + edgeRuntimeCacheVolume, ensureDockerNamedVolume, ensureDockerNetwork, isDockerRunning, - localDockerId, resolveDockerNetworkMode, resolveEdgeRuntimeVersion, resolveFunctionsDockerImage, runChildProcess, + runChildProcessBinaryStdout, } from "./functions-docker.ts"; -import { loadFunctionsCliConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; import { usesSlimImageRuntime } from "../services/slim-images.ts"; +import { loadFunctionsCliConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; import { edgeRuntimeImage, FUNCTIONS_BUNDLER_MUTEX_GROUP, @@ -50,12 +53,17 @@ const legacyEntrypointPath = "file:///src/index.ts"; // deploy's `toDockerPath` host-mirroring scheme. const DOCKER_DENO_DIR = "/home/deno"; const dockerIoEszipDir = "/root/eszips"; +// Slim (uid 65532) counterparts: `/tmp` is the one image directory writable +// and traversable by the non-root user on every published slim tag (older +// slim images ship `/root` as 0700), so the eszip bind and the unbundle +// output both live under it. The output deliberately is NOT a bind: `docker +// cp` reads it back after the container exits (binds and volumes are +// invisible to `cp` on a stopped container), which is what keeps uid 65532 +// from ever writing into the host `supabase/functions` directory — on native +// Linux that bind write EACCESes whenever the host directory belongs to a +// different uid. const slimEszipDir = "/tmp/eszips"; -const dockerIoDenoCacheDir = "/root/.cache/deno"; -// Slim HOME `/home/nonroot` is 0700 uid 65532. A named volume inherits that -// owner only when the mount path already exists; `/tmp/.cache/deno` does not, -// so Deno's default `$HOME/.cache/deno` never used a /tmp bind. -const slimDenoCacheDir = "/home/nonroot"; +const slimUnbundleDir = "/tmp/unbundle"; export interface DownloadFunctionsOptions { readonly functionName: Option.Option; @@ -1072,10 +1080,19 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( const { projectId, denoVersion, image, projectEnvValues } = edgeRuntimeImage; const functionsDir = resolve(dependencies.projectRoot, "supabase", "functions"); const hostEszipPath = resolve(eszipPath); + // The slim edge-runtime image runs as uid 65532, which cannot write into + // the host `supabase/functions` bind the docker.io (root) flow extracts + // straight into — on native Linux that write EACCESes whenever the host + // directory belongs to a different uid. The slim flow therefore keeps the + // container's filesystem as the only write target and copies the extracted + // files out with `docker cp` afterwards, writing them host-side as the + // invoking user. See `slimEszipDir`'s doc for the path choices. const slim = usesSlimImageRuntime(image); + const cacheVolume = edgeRuntimeCacheVolume(projectId, slim); const dockerEszipPath = posix.join(slim ? slimEszipDir : dockerIoEszipDir, eszipFileName); - const dockerOutputPath = posix.join(DOCKER_DENO_DIR, slug); - const dockerDenoCacheDir = slim ? slimDenoCacheDir : dockerIoDenoCacheDir; + const dockerOutputPath = slim + ? posix.join(slimUnbundleDir, slug) + : posix.join(DOCKER_DENO_DIR, slug); // Go: `viper.GetString("network-id")` else `NetId` (`docker.go:379-383`) — // `--network-id` is a persistent root flag (`cmd/root.go:328`), not @@ -1100,7 +1117,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)), ); @@ -1110,21 +1127,20 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( // explicit creation — `docker run -v :...` would otherwise still // implicitly create the named volume, which Bitbucket's restricted Docker // environment doesn't allow, same carve-out as `deploy.ts`'s - // `buildDockerBinds`. + // `buildDockerBinds`. The slim flow drops the `supabase/functions` bind: + // its output stays container-local and is copied out below instead. const binds = [ - ...(process.env["BITBUCKET_CLONE_DIR"] === undefined - ? [`${localDockerId("edge_runtime", projectId)}:${dockerDenoCacheDir}:rw`] - : []), + ...(process.env["BITBUCKET_CLONE_DIR"] === undefined ? [cacheVolume.bind] : []), `${hostEszipPath}:${dockerEszipPath}:ro`, - `${functionsDir}:${DOCKER_DENO_DIR}:rw`, + ...(slim ? [] : [`${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 @@ -1134,51 +1150,200 @@ 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"), + 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 (!slim) { + 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 + // the `unbundle` container itself wrote. + return slug; + } + + // Slim: `create` → `start --attach` → `cp` out → `rm -f`, instead of a + // single `docker run --rm` — the extracted files live on the container's + // own filesystem, so the container must still exist after it exits for + // `docker cp` to read them. + const containerName = `supabase_unbundle_${slug}_${randomUUID().slice(0, 8)}`; + yield* Effect.gen(function* () { + const created = yield* runChildProcess( + "docker", + buildFunctionsDockerCreateArgs(containerName, spec), + { stdout: "ignore", stderr: "pipe" }, + ).pipe( + Effect.mapError( + withDockerStepFailure( + "failed to create the edge-runtime unbundle container", + slug, + styleAqua, + ), + ), + ); + if (created.exitCode !== 0) { + return yield* Effect.fail( + Object.assign( + new Error( + `failed to create the edge-runtime unbundle container: ${ + created.stderr.trim().length > 0 + ? created.stderr.trim() + : `exit ${created.exitCode}` + }`, + ), + { suggestion: suggestLegacyBundle(slug, styleAqua) }, + ), + ); + } + + yield* runUnbundleContainer(["start", "--attach", containerName]); + + const copied = yield* runChildProcessBinaryStdout("docker", [ + "cp", + `${containerName}:${dockerOutputPath}`, + "-", + ]).pipe( + Effect.mapError( + withDockerStepFailure( + "failed to copy the extracted Function files out of the unbundle container", + slug, + styleAqua, + ), + ), + ); + if (copied.exitCode !== 0) { + return yield* Effect.fail( + Object.assign( + new Error( + `failed to copy the extracted Function files out of the unbundle container: ${ + copied.stderr.trim().length > 0 ? copied.stderr.trim() : `exit ${copied.exitCode}` + }`, + ), + { suggestion: suggestLegacyBundle(slug, styleAqua) }, + ), + ); + } + + yield* writeUnbundledArchive(copied.stdout, functionsDir, slug); }).pipe( - Effect.mapError( - withDockerStepFailure("failed to run the edge-runtime unbundle container", slug, styleAqua), + // Same cleanup guarantee `--rm` gives the docker.io flow: the + // container is removed on every path out of the lifecycle above, + // including a failed start or copy. Removal failures are swallowed — + // an orphaned stopped container must not mask the real error. + Effect.ensuring( + runChildProcess("docker", ["rm", "--force", containerName], { + stdout: "ignore", + stderr: "ignore", + }).pipe(Effect.ignore), ), ); - 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, - }), - ); - } - - // Go: `downloadWithDockerUnbundle` has no final "Downloaded Function ..." - // print, unlike `RunLegacy`/`downloadWithServerSideUnbundle` — its only - // stdout/stderr text is "Downloading function: ..." above plus whatever - // the `unbundle` container itself wrote. return slug; }); return yield* extract.pipe(Effect.ensuring(cleanupEszip)); }); +/** + * Writes the `docker cp`-exported unbundle output (a tar rooted at the slug + * directory) into `/`, merging over existing files the + * same way the docker.io flow's direct bind write does — but host-side, as + * the invoking user, which is the whole point of the slim copy-out flow. + * Entry paths originate from a container run over a remote eszip, so each + * destination is containment-checked against `functionsRoot` (resolved and + * realpath'd like `downloadSingle`'s multipart writes) before anything is + * written. + */ +const writeUnbundledArchive = Effect.fnUntraced(function* ( + archive: Uint8Array, + functionsRoot: string, + slug: string, +) { + const files = yield* Effect.tryPromise({ + try: () => containerArchiveFiles(archive), + catch: (cause) => + new Error( + `failed to read the extracted Function files: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + }); + + const functionDir = join(functionsRoot, slug); + const mapMkdirError = (cause: unknown) => + new Error(`failed to mkdir: ${cause instanceof Error ? cause.message : String(cause)}`); + yield* Effect.tryPromise({ + try: () => mkdir(functionDir, { recursive: true }), + catch: mapMkdirError, + }); + const realFunctionsRoot = yield* Effect.tryPromise({ + try: () => realpath(functionsRoot), + catch: mapMkdirError, + }); + + for (const [name, body] of files) { + // `docker cp : -` roots every entry at the copied + // directory's basename — the slug. + const segments = name.replace(/^\.\//, "").split("/"); + const parts = segments[0] === slug ? segments.slice(1) : segments; + if (parts.every((part) => part.length === 0)) { + continue; + } + + const destination = resolve(functionDir, ...parts); + yield* ensureContainedPath(resolve(functionsRoot), destination, name); + const parent = dirname(destination); + yield* Effect.tryPromise({ + try: () => mkdir(parent, { recursive: true }), + catch: mapMkdirError, + }); + // A pre-existing symlinked subdirectory must not let a crafted entry + // path escape the functions root once resolved on disk. + yield* Effect.tryPromise({ try: () => realpath(parent), catch: mapMkdirError }).pipe( + Effect.flatMap((realParent) => ensureContainedPath(realFunctionsRoot, realParent, name)), + ); + yield* writeFileWithoutFollowingSymlinks(destination, body, name); + } +}); + const downloadSingle = Effect.fnUntraced(function* ( dependencies: DownloadRuntimeDependencies, projectRef: string, diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 24cfa2bc5d..124ec0f420 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -29,6 +29,42 @@ export function localDockerId(name: string, projectId: string) { return `supabase_${name}_${normalizeProjectId(projectId)}`; } +/** + * The slim edge-runtime image's home directory (distroless `nonroot`, uid + * 65532) — same layout contract the slim Storage volume mount relies on + * (`legacy/commands/start/services/storage.service.ts`). + */ +const SLIM_EDGE_RUNTIME_HOME = "/home/nonroot"; + +/** + * The Deno-cache volume bind for an edge-runtime container. + * + * docker.io edge-runtime runs as root, so the shared + * `supabase_edge_runtime_` volume mounts at `/root/.cache/deno` + * (Deno's cache under root's `$HOME`) and its contents end up root-owned. + * + * The slim image runs as uid 65532 with `$HOME=/home/nonroot`, which breaks + * that contract twice over: Deno resolves its cache under `/home/nonroot`, + * not `/root`, and a volume already seeded root-owned by a docker.io run can + * never be made writable by 65532 by remounting it. So slim runs use their + * OWN volume (`supabase_edge_runtime_slim_`) mounted over the + * home directory: Docker seeds a fresh volume from the image's + * `/home/nonroot` (owned by 65532), so `$HOME`-relative caches (deno, npm) + * land inside it writable — the same mount-the-owned-home-dir pattern the + * slim Storage container uses. The two volume families never share a name, + * so no cross-family ownership probe is needed; both carry the + * `com.supabase.cli.project` label `supabase stop --no-backup` prunes by. + */ +export function edgeRuntimeCacheVolume(projectId: string, slim: boolean) { + const name = localDockerId(slim ? "edge_runtime_slim" : "edge_runtime", projectId); + const containerPath = slim ? SLIM_EDGE_RUNTIME_HOME : "/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 @@ -87,7 +123,7 @@ export function toDockerPath(hostPath: string) { * carry its `0644` default. */ export function containerArchiveBytes( - files: Readonly>, + files: Readonly>, ): Promise { return new Bun.Archive( Object.fromEntries( @@ -99,6 +135,26 @@ export function containerArchiveBytes( ).bytes(); } +/** + * Inverse of {@link containerArchiveBytes}: decodes a `docker cp + * : -` tar stream into entry-name → file-bytes. Directory + * entries (trailing `/`) are dropped — callers recreate parents from the + * file paths themselves. + */ +export async function containerArchiveFiles( + archive: Uint8Array, +): Promise> { + const entries = await new Bun.Archive(archive).files(); + const files = new Map(); + for (const [name, blob] of entries) { + if (name.endsWith("/")) { + continue; + } + files.set(name, new Uint8Array(await blob.arrayBuffer())); + } + return files; +} + export interface FunctionsDockerRunSpec { /** Already registry/pull-resolved image reference. */ readonly image: string; @@ -131,7 +187,28 @@ 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); +} + +/** + * `docker create --name ` variant of + * {@link buildFunctionsDockerRunArgs} — for one-shot containers whose + * filesystem must be reachable by `docker cp` after they exit (a `--rm` run + * removes it before anything can be copied out). The caller owns the + * `start -a` / `cp` / `rm -f` lifecycle around it. + */ +export function buildFunctionsDockerCreateArgs( + containerName: string, + spec: FunctionsDockerRunSpec, +): Array { + return buildFunctionsDockerContainerArgs(["create", "--name", containerName], 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"); @@ -232,6 +309,52 @@ export const runChildProcess = Effect.fnUntraced(function* ( ); }); +/** + * {@link runChildProcess} variant whose stdout is collected as raw bytes + * instead of decoded text — for `docker cp : -`, whose + * stdout is a tar archive that UTF-8 decoding would corrupt. stderr stays + * text (it only ever carries diagnostics). + */ +export const runChildProcessBinaryStdout = Effect.fnUntraced(function* ( + command: string, + args: ReadonlyArray, +) { + return yield* Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawnContainerCli(spawner, [...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + extendEnv: command === "docker", + }); + + const stdoutChunks: Array = []; + const [, stderr, exitCode] = yield* Effect.all( + [ + Stream.runForEach(child.stdout, (chunk) => + Effect.sync(() => { + stdoutChunks.push(chunk); + }), + ), + collectByteStream(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + const total = stdoutChunks.reduce((size, chunk) => size + chunk.length, 0); + const stdout = new Uint8Array(total); + let offset = 0; + for (const chunk of stdoutChunks) { + stdout.set(chunk, offset); + offset += chunk.length; + } + return { exitCode, stdout, stderr }; + }), + ); +}); + // Go: `container.NetworkMode.IsContainer()` (`docker/api/types/container/hostconfig.go:152-155`, // via the unexported `containerID` helper, same file:493-499) — `--network container:` // (Docker's syntax for attaching to another container's network stack) is recognized by a bare 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..4a4282c40b 100644 --- a/apps/cli/src/shared/functions/functions-docker.unit.test.ts +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -5,11 +5,15 @@ import { Deferred, Effect, Layer, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { + buildFunctionsDockerCreateArgs, buildFunctionsDockerRunArgs, containerArchiveBytes, + containerArchiveFiles, + edgeRuntimeCacheVolume, localDockerId, resolveDockerNetworkMode, runChildProcess, + runChildProcessBinaryStdout, toDockerPath, } from "./functions-docker.ts"; @@ -211,6 +215,47 @@ describe("buildFunctionsDockerRunArgs", () => { }); }); +describe("edgeRuntimeCacheVolume", () => { + it("keeps the shared volume at /root/.cache/deno for docker.io images", () => { + expect(edgeRuntimeCacheVolume("my-project", false)).toEqual({ + name: "supabase_edge_runtime_my-project", + containerPath: "/root/.cache/deno", + bind: "supabase_edge_runtime_my-project:/root/.cache/deno:rw", + }); + }); + + it("uses a slim-only volume mounted over the nonroot home for slim images", () => { + // A docker.io-seeded volume is root-owned and can never be made writable + // by uid 65532 by remounting it, so the two image families must never + // share a volume name — see the helper's doc comment. + expect(edgeRuntimeCacheVolume("my-project", true)).toEqual({ + name: "supabase_edge_runtime_slim_my-project", + containerPath: "/home/nonroot", + bind: "supabase_edge_runtime_slim_my-project:/home/nonroot:rw", + }); + }); +}); + +describe("buildFunctionsDockerCreateArgs", () => { + it("emits create --name with the same tail as the run variant", () => { + const spec = { + image: "ghcr.io/supabase/cli/edge-runtime:v1.2.3", + projectId: "my-project", + networkMode: "supabase_network_my-project", + binds: ["/host/a:/container/a:ro"], + containerArgs: ["unbundle", "--eszip", "/tmp/eszips/x.eszip"], + platform: "darwin" as const, + }; + + expect(buildFunctionsDockerCreateArgs("supabase_unbundle_x_1234", spec)).toEqual([ + "create", + "--name", + "supabase_unbundle_x_1234", + ...buildFunctionsDockerRunArgs(spec).slice(2), + ]); + }); +}); + describe("containerArchiveBytes", () => { // Regular-file tar entries parsed straight from the ustar headers. function tarRegularFileEntries(archive: Uint8Array): ReadonlyArray<[string, number]> { @@ -245,6 +290,28 @@ 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("containerArchiveFiles", () => { + it("round-trips file entries, preserving binary bodies", async () => { + const binary = new Uint8Array([0x00, 0x80, 0xff, 0x10]); + const archive = await containerArchiveBytes({ + "myfn/index.ts": "console.log(1)\n", + "myfn/nested/util.bin": binary, + }); + + const files = await containerArchiveFiles(archive); + expect([...files.keys()].sort()).toEqual(["myfn/index.ts", "myfn/nested/util.bin"]); + expect(new TextDecoder().decode(files.get("myfn/index.ts"))).toBe("console.log(1)\n"); + expect(files.get("myfn/nested/util.bin")).toEqual(binary); + }); }); describe("resolveDockerNetworkMode", () => { @@ -360,3 +427,24 @@ describe("runChildProcess", () => { }), ); }); + +describe("runChildProcessBinaryStdout", () => { + it.effect("returns stdout as raw bytes, immune to UTF-8 decoding corruption", () => + Effect.gen(function* () { + // 0x80/0xFF are invalid UTF-8 lead bytes — text decoding would replace + // them (U+FFFD), corrupting a tar stream. The binary collector must + // return them verbatim, across chunk boundaries. + const chunk1 = new Uint8Array([0x00, 0x80]); + const chunk2 = new Uint8Array([0xff, 0x42]); + + const result = yield* runChildProcessBinaryStdout("docker", [ + "cp", + "container:/tmp/unbundle/x", + "-", + ]).pipe(Effect.provide(mockStreamingChildProcessLayer({ stdout: [chunk1, chunk2] }))); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toEqual(new Uint8Array([0x00, 0x80, 0xff, 0x42])); + }), + ); +}); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 0b557813d3..2e7f5ce94e 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -64,6 +64,7 @@ import { import { containerArchiveBytes, dockerProjectLabels, + edgeRuntimeCacheVolume, ensureDockerNamedVolume, ensureDockerNetwork, localDockerId, @@ -1655,6 +1656,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo const watchableBinds = new Map(); const emittedScopeWarnings = new Set(); const functionsConfig: Record = {}; + const slimEdgeRuntime = usesSlimImageRuntime(input.image); for (const config of functionConfigs) { if (!config.enabled) { @@ -1709,7 +1711,10 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo const binds = [...functionBinds.values()]; - yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId); + yield* ensureDockerNamedVolume( + edgeRuntimeCacheVolume(projectId, slimEdgeRuntime).name, + projectId, + ); yield* ensureDockerNetwork(networkMode, projectId); const env = [ @@ -1762,7 +1767,6 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo }); const labels = dockerProjectLabels(projectId); - const slimEdgeRuntime = usesSlimImageRuntime(input.image); const serveMainDir = slimEdgeRuntime ? slimServeMainDir : dockerIoServeMainDir; const serveMainFile = `${serveMainDir}/index.ts`; const runtimeCommand = [ From 9b2bcba950e264b4bf7a595ccd61dc88ec8e761a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 08:06:43 +0000 Subject: [PATCH 21/24] ci(cli): temporary slim edge-runtime image validation workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch-local workflow_dispatch job that pulls the released slim edge-runtime image and validates it against this branch's slim functions Docker flows on a native Linux runner (CLI uid 1001 vs container uid 65532 — the condition the original EACCES reports came from): image layout smoke (0755 /root, busybox sh+cat, nonroot home), a negative control reproducing the pre-fix direct-bind EACCES, slim download via cp-out staging, slim deploy from a /root checkout, the slim-only Deno-cache volume, and the docker.io baseline. To be removed once the image + CLI pairing is validated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DrSmrBgbWfva1KbYWGMV4c --- .../slim-edge-runtime-validation.yml | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 .github/workflows/slim-edge-runtime-validation.yml diff --git a/.github/workflows/slim-edge-runtime-validation.yml b/.github/workflows/slim-edge-runtime-validation.yml new file mode 100644 index 0000000000..5f8bb5f919 --- /dev/null +++ b/.github/workflows/slim-edge-runtime-validation.yml @@ -0,0 +1,230 @@ +name: slim-edge-runtime-validation + +# TEMPORARY branch-local validation workflow — exercises the released slim +# edge-runtime image (supabase/slim-services chmod-/root + busybox sh/cat +# release) against this branch's slim `functions` Docker flows on a native +# Linux runner, where the CLI user (uid 1001) differs from the container +# user (uid 65532) — the exact condition the original EACCES reports came +# from. Remove this file once the image + CLI pairing is validated. + +# `workflow_dispatch` only works for workflows registered on the default +# branch, so this branch-local workflow triggers on pushes touching itself. +on: + push: + branches: + - avallete/supabase-slim-images-flag-c95c16 + paths: + - .github/workflows/slim-edge-runtime-validation.yml + +permissions: + contents: read + +jobs: + validate: + name: Validate slim edge-runtime image + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + IMAGE: ghcr.io/supabase/cli/edge-runtime:v1.74.3 + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + + - name: Pull released slim image + run: | + set -euxo pipefail + docker pull "$IMAGE" + docker image inspect "$IMAGE" --format 'User={{.Config.User}} Entrypoint={{.Config.Entrypoint}} Env={{.Config.Env}}' + + - name: Image layout smoke (0755 /root, nonroot home, busybox sh+cat) + run: | + set -euxo pipefail + # Runs as the image's own (non-root) user. The slim image ships only + # the `sh`/`cat` applet symlinks, so every other coreutil goes + # through the busybox multi-call binary directly. + docker run --rm --entrypoint /usr/bin/sh "$IMAGE" -c ' + set -eux + bb=$(command -v busybox || echo /usr/bin/busybox) + [ "$($bb id -u)" = "65532" ] + $bb ls -ld /root /home/nonroot /tmp + [ "$($bb ls -ld /root | $bb cut -c1-10)" = "drwxr-xr-x" ] + [ "$($bb stat -c %u /home/nonroot)" = "65532" ] + command -v cat + ' + # uid 65532 can read a bind mounted under /root (CI-checkout shape). + echo probe-ok > "$RUNNER_TEMP/probe-file" + docker run --rm -v "$RUNNER_TEMP/probe-file:/root/probe/file:ro" \ + --entrypoint /usr/bin/cat "$IMAGE" /root/probe/file | grep -qx probe-ok + # sh -c heredoc + exec (the CLI's edge-runtime script-runner shape). + docker run --rm --entrypoint /usr/bin/sh "$IMAGE" -c ' + cat <<'"'"'SENTINEL'"'"' > /tmp/heredoc-probe + hello + SENTINEL + exec cat /tmp/heredoc-probe + ' | grep -qx hello + + - name: Prepare project, eszip, and mock Management API + run: | + set -euxo pipefail + PROJ="$RUNNER_TEMP/proj" + mkdir -p "$PROJ" + cd "$PROJ" + bun "$GITHUB_WORKSPACE/apps/cli/src/legacy/main.ts" init --force + mkdir -p supabase/functions/myfn + cat > supabase/functions/myfn/index.ts <<'EOF' + Deno.serve(async () => new Response("hello from slim validation")); + EOF + + # Bundle a genuine eszip with the slim image itself, writing into a + # 0777 staging dir — the same shape `functions deploy`'s bundler + # uses (validates the uid-65532 output write). + FNOUT="$RUNNER_TEMP/fnout" + mkdir -p "$FNOUT" + chmod 777 "$FNOUT" + docker run --rm \ + -v "$PROJ/supabase/functions:/src:ro" \ + -v "$FNOUT:/out" \ + "$IMAGE" bundle --entrypoint /src/myfn/index.ts --output /out/output.eszip + ls -la "$FNOUT" + + cat > "$RUNNER_TEMP/mock-api.ts" <<'EOF' + const eszipPath = process.argv[2]; + const port = Number(process.argv[3] ?? "8899"); + const eszip = await Bun.file(eszipPath).arrayBuffer(); + const fn = (slug: string) => ({ + id: "00000000-0000-0000-0000-000000000000", + slug, + name: slug, + status: "ACTIVE", + version: 1, + created_at: 1700000000000, + updated_at: 1700000000000, + verify_jwt: true, + entrypoint_path: "file:///src/index.ts", + }); + Bun.serve({ + port, + hostname: "127.0.0.1", + fetch(req) { + const url = new URL(req.url); + console.error(`[mock-api] ${req.method} ${url.pathname}`); + if (/^\/v1\/projects\/[^/]+\/functions\/[^/]+\/body$/.test(url.pathname)) { + return new Response(eszip, { + status: 200, + headers: { "Content-Type": "application/octet-stream" }, + }); + } + if (req.method !== "GET") return Response.json(fn("myfn")); + const single = url.pathname.match(/^\/v1\/projects\/[^/]+\/functions\/([^/]+)$/); + if (single) return Response.json(fn(single[1]!)); + if (/^\/v1\/projects\/[^/]+\/functions$/.test(url.pathname)) { + return Response.json([fn("myfn")]); + } + return Response.json({ message: "not found" }, { status: 404 }); + }, + }); + console.error(`[mock-api] listening on http://127.0.0.1:${port}`); + EOF + nohup bun "$RUNNER_TEMP/mock-api.ts" "$FNOUT/output.eszip" 8899 > "$RUNNER_TEMP/mock-api.log" 2>&1 & + for i in $(seq 1 20); do + curl -sSf -o /dev/null http://127.0.0.1:8899/v1/projects/x/functions && break + sleep 0.5 + done + curl -sSf -o /dev/null http://127.0.0.1:8899/v1/projects/x/functions + + cat > "$RUNNER_TEMP/profile.yaml" <<'EOF' + name: slim-validation + api_url: "http://127.0.0.1:8899" + dashboard_url: "http://127.0.0.1:8899" + project_host: supabase.red + pooler_host: 127.0.0.1 + EOF + + - name: "Negative control: the pre-fix direct bind write still EACCESes as uid 65532" + run: | + set -euxo pipefail + PROJ="$RUNNER_TEMP/proj" + ls -ldn "$PROJ/supabase/functions" + if docker run --rm \ + -v "$RUNNER_TEMP/fnout/output.eszip:/tmp/eszips/output.eszip:ro" \ + -v "$PROJ/supabase/functions:/home/deno:rw" \ + "$IMAGE" unbundle --eszip /tmp/eszips/output.eszip --output /home/deno/oldfail; then + echo "expected the old direct-bind unbundle write to fail on a runner-owned directory" >&2 + exit 1 + fi + [ ! -e "$PROJ/supabase/functions/oldfail" ] + + - name: "Scenario 1: slim functions download --use-docker (cp-out staging)" + env: + SUPABASE_USE_SLIM_IMAGES: "true" + run: | + set -euxo pipefail + # Dummy token, assembled at runtime so push protection never + # pattern-matches a literal. + export SUPABASE_ACCESS_TOKEN="sbp_$(printf 'a%.0s' $(seq 40))" + export SUPABASE_PROFILE="$RUNNER_TEMP/profile.yaml" + cd "$RUNNER_TEMP/proj" + rm -rf supabase/functions/myfn + bun "$GITHUB_WORKSPACE/apps/cli/src/legacy/main.ts" functions download myfn \ + --project-ref abcdefghijklmnopqrst --use-docker + test -f supabase/functions/myfn/index.ts + grep -q "hello from slim validation" supabase/functions/myfn/index.ts + # Files belong to the invoking user, not the container uid. + [ "$(stat -c %u supabase/functions/myfn/index.ts)" = "$(id -u)" ] + # No leftover staging container. + [ -z "$(docker ps -aq --filter name=supabase_unbundle_)" ] + + - name: "Scenario 2: slim functions deploy --use-docker from a checkout under /root" + env: + SUPABASE_USE_SLIM_IMAGES: "true" + run: | + set -euxo pipefail + export SUPABASE_ACCESS_TOKEN="sbp_$(printf 'a%.0s' $(seq 40))" + sudo cp -r "$RUNNER_TEMP/proj" /root/proj + # Resolve the real bun binary: a mise shim would re-resolve against + # root's HOME under sudo and fail. + BUN_BIN="$(mise which bun 2>/dev/null || command -v bun)" + sudo env "PATH=$PATH" \ + SUPABASE_ACCESS_TOKEN="$SUPABASE_ACCESS_TOKEN" \ + SUPABASE_PROFILE="$RUNNER_TEMP/profile.yaml" \ + SUPABASE_USE_SLIM_IMAGES=true \ + bash -c "cd /root/proj && '$BUN_BIN' '$GITHUB_WORKSPACE/apps/cli/src/legacy/main.ts' functions deploy myfn --project-ref abcdefghijklmnopqrst --use-docker" + + - name: "Scenario 3: slim Deno-cache volume is seeded and writable by uid 65532" + run: | + set -euxo pipefail + docker volume ls --format '{{.Name}}' | grep -x supabase_edge_runtime_slim_proj + docker run --rm -v supabase_edge_runtime_slim_proj:/probe \ + --entrypoint /usr/bin/sh "$IMAGE" -c ' + set -eux + bb=$(command -v busybox || echo /usr/bin/busybox) + [ "$($bb stat -c %u /probe)" = "65532" ] + $bb ls -la /probe + $bb touch /probe/.write-probe + ' + + - name: "Scenario 4: docker.io baseline is unchanged (direct bind write as root)" + run: | + set -euxo pipefail + export SUPABASE_ACCESS_TOKEN="sbp_$(printf 'a%.0s' $(seq 40))" + export SUPABASE_PROFILE="$RUNNER_TEMP/profile.yaml" + cd "$RUNNER_TEMP/proj" + sudo rm -rf supabase/functions/myfn + bun "$GITHUB_WORKSPACE/apps/cli/src/legacy/main.ts" functions download myfn \ + --project-ref abcdefghijklmnopqrst --use-docker + sudo test -f supabase/functions/myfn/index.ts + docker volume ls --format '{{.Name}}' | grep -x supabase_edge_runtime_proj + + - name: Mock API log + if: always() + run: cat "$RUNNER_TEMP/mock-api.log" || true From 2066df566d9f6857d4c3a795168d7fe1cf57c661 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 08:14:36 +0000 Subject: [PATCH 22/24] ci(cli): remove temporary slim edge-runtime validation workflow Validation run passed against the released slim edge-runtime v1.74.3 image (supabase/cli actions run 33154293277): image layout smoke, pre-fix EACCES negative control, slim download cp-out staging, slim deploy from a /root checkout, slim-only Deno-cache volume, and the docker.io baseline. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DrSmrBgbWfva1KbYWGMV4c --- .../slim-edge-runtime-validation.yml | 230 ------------------ 1 file changed, 230 deletions(-) delete mode 100644 .github/workflows/slim-edge-runtime-validation.yml diff --git a/.github/workflows/slim-edge-runtime-validation.yml b/.github/workflows/slim-edge-runtime-validation.yml deleted file mode 100644 index 5f8bb5f919..0000000000 --- a/.github/workflows/slim-edge-runtime-validation.yml +++ /dev/null @@ -1,230 +0,0 @@ -name: slim-edge-runtime-validation - -# TEMPORARY branch-local validation workflow — exercises the released slim -# edge-runtime image (supabase/slim-services chmod-/root + busybox sh/cat -# release) against this branch's slim `functions` Docker flows on a native -# Linux runner, where the CLI user (uid 1001) differs from the container -# user (uid 65532) — the exact condition the original EACCES reports came -# from. Remove this file once the image + CLI pairing is validated. - -# `workflow_dispatch` only works for workflows registered on the default -# branch, so this branch-local workflow triggers on pushes touching itself. -on: - push: - branches: - - avallete/supabase-slim-images-flag-c95c16 - paths: - - .github/workflows/slim-edge-runtime-validation.yml - -permissions: - contents: read - -jobs: - validate: - name: Validate slim edge-runtime image - runs-on: ubuntu-latest - timeout-minutes: 30 - env: - IMAGE: ghcr.io/supabase/cli/edge-runtime:v1.74.3 - defaults: - run: - shell: bash - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Setup - uses: ./.github/actions/setup - with: - dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - - - name: Pull released slim image - run: | - set -euxo pipefail - docker pull "$IMAGE" - docker image inspect "$IMAGE" --format 'User={{.Config.User}} Entrypoint={{.Config.Entrypoint}} Env={{.Config.Env}}' - - - name: Image layout smoke (0755 /root, nonroot home, busybox sh+cat) - run: | - set -euxo pipefail - # Runs as the image's own (non-root) user. The slim image ships only - # the `sh`/`cat` applet symlinks, so every other coreutil goes - # through the busybox multi-call binary directly. - docker run --rm --entrypoint /usr/bin/sh "$IMAGE" -c ' - set -eux - bb=$(command -v busybox || echo /usr/bin/busybox) - [ "$($bb id -u)" = "65532" ] - $bb ls -ld /root /home/nonroot /tmp - [ "$($bb ls -ld /root | $bb cut -c1-10)" = "drwxr-xr-x" ] - [ "$($bb stat -c %u /home/nonroot)" = "65532" ] - command -v cat - ' - # uid 65532 can read a bind mounted under /root (CI-checkout shape). - echo probe-ok > "$RUNNER_TEMP/probe-file" - docker run --rm -v "$RUNNER_TEMP/probe-file:/root/probe/file:ro" \ - --entrypoint /usr/bin/cat "$IMAGE" /root/probe/file | grep -qx probe-ok - # sh -c heredoc + exec (the CLI's edge-runtime script-runner shape). - docker run --rm --entrypoint /usr/bin/sh "$IMAGE" -c ' - cat <<'"'"'SENTINEL'"'"' > /tmp/heredoc-probe - hello - SENTINEL - exec cat /tmp/heredoc-probe - ' | grep -qx hello - - - name: Prepare project, eszip, and mock Management API - run: | - set -euxo pipefail - PROJ="$RUNNER_TEMP/proj" - mkdir -p "$PROJ" - cd "$PROJ" - bun "$GITHUB_WORKSPACE/apps/cli/src/legacy/main.ts" init --force - mkdir -p supabase/functions/myfn - cat > supabase/functions/myfn/index.ts <<'EOF' - Deno.serve(async () => new Response("hello from slim validation")); - EOF - - # Bundle a genuine eszip with the slim image itself, writing into a - # 0777 staging dir — the same shape `functions deploy`'s bundler - # uses (validates the uid-65532 output write). - FNOUT="$RUNNER_TEMP/fnout" - mkdir -p "$FNOUT" - chmod 777 "$FNOUT" - docker run --rm \ - -v "$PROJ/supabase/functions:/src:ro" \ - -v "$FNOUT:/out" \ - "$IMAGE" bundle --entrypoint /src/myfn/index.ts --output /out/output.eszip - ls -la "$FNOUT" - - cat > "$RUNNER_TEMP/mock-api.ts" <<'EOF' - const eszipPath = process.argv[2]; - const port = Number(process.argv[3] ?? "8899"); - const eszip = await Bun.file(eszipPath).arrayBuffer(); - const fn = (slug: string) => ({ - id: "00000000-0000-0000-0000-000000000000", - slug, - name: slug, - status: "ACTIVE", - version: 1, - created_at: 1700000000000, - updated_at: 1700000000000, - verify_jwt: true, - entrypoint_path: "file:///src/index.ts", - }); - Bun.serve({ - port, - hostname: "127.0.0.1", - fetch(req) { - const url = new URL(req.url); - console.error(`[mock-api] ${req.method} ${url.pathname}`); - if (/^\/v1\/projects\/[^/]+\/functions\/[^/]+\/body$/.test(url.pathname)) { - return new Response(eszip, { - status: 200, - headers: { "Content-Type": "application/octet-stream" }, - }); - } - if (req.method !== "GET") return Response.json(fn("myfn")); - const single = url.pathname.match(/^\/v1\/projects\/[^/]+\/functions\/([^/]+)$/); - if (single) return Response.json(fn(single[1]!)); - if (/^\/v1\/projects\/[^/]+\/functions$/.test(url.pathname)) { - return Response.json([fn("myfn")]); - } - return Response.json({ message: "not found" }, { status: 404 }); - }, - }); - console.error(`[mock-api] listening on http://127.0.0.1:${port}`); - EOF - nohup bun "$RUNNER_TEMP/mock-api.ts" "$FNOUT/output.eszip" 8899 > "$RUNNER_TEMP/mock-api.log" 2>&1 & - for i in $(seq 1 20); do - curl -sSf -o /dev/null http://127.0.0.1:8899/v1/projects/x/functions && break - sleep 0.5 - done - curl -sSf -o /dev/null http://127.0.0.1:8899/v1/projects/x/functions - - cat > "$RUNNER_TEMP/profile.yaml" <<'EOF' - name: slim-validation - api_url: "http://127.0.0.1:8899" - dashboard_url: "http://127.0.0.1:8899" - project_host: supabase.red - pooler_host: 127.0.0.1 - EOF - - - name: "Negative control: the pre-fix direct bind write still EACCESes as uid 65532" - run: | - set -euxo pipefail - PROJ="$RUNNER_TEMP/proj" - ls -ldn "$PROJ/supabase/functions" - if docker run --rm \ - -v "$RUNNER_TEMP/fnout/output.eszip:/tmp/eszips/output.eszip:ro" \ - -v "$PROJ/supabase/functions:/home/deno:rw" \ - "$IMAGE" unbundle --eszip /tmp/eszips/output.eszip --output /home/deno/oldfail; then - echo "expected the old direct-bind unbundle write to fail on a runner-owned directory" >&2 - exit 1 - fi - [ ! -e "$PROJ/supabase/functions/oldfail" ] - - - name: "Scenario 1: slim functions download --use-docker (cp-out staging)" - env: - SUPABASE_USE_SLIM_IMAGES: "true" - run: | - set -euxo pipefail - # Dummy token, assembled at runtime so push protection never - # pattern-matches a literal. - export SUPABASE_ACCESS_TOKEN="sbp_$(printf 'a%.0s' $(seq 40))" - export SUPABASE_PROFILE="$RUNNER_TEMP/profile.yaml" - cd "$RUNNER_TEMP/proj" - rm -rf supabase/functions/myfn - bun "$GITHUB_WORKSPACE/apps/cli/src/legacy/main.ts" functions download myfn \ - --project-ref abcdefghijklmnopqrst --use-docker - test -f supabase/functions/myfn/index.ts - grep -q "hello from slim validation" supabase/functions/myfn/index.ts - # Files belong to the invoking user, not the container uid. - [ "$(stat -c %u supabase/functions/myfn/index.ts)" = "$(id -u)" ] - # No leftover staging container. - [ -z "$(docker ps -aq --filter name=supabase_unbundle_)" ] - - - name: "Scenario 2: slim functions deploy --use-docker from a checkout under /root" - env: - SUPABASE_USE_SLIM_IMAGES: "true" - run: | - set -euxo pipefail - export SUPABASE_ACCESS_TOKEN="sbp_$(printf 'a%.0s' $(seq 40))" - sudo cp -r "$RUNNER_TEMP/proj" /root/proj - # Resolve the real bun binary: a mise shim would re-resolve against - # root's HOME under sudo and fail. - BUN_BIN="$(mise which bun 2>/dev/null || command -v bun)" - sudo env "PATH=$PATH" \ - SUPABASE_ACCESS_TOKEN="$SUPABASE_ACCESS_TOKEN" \ - SUPABASE_PROFILE="$RUNNER_TEMP/profile.yaml" \ - SUPABASE_USE_SLIM_IMAGES=true \ - bash -c "cd /root/proj && '$BUN_BIN' '$GITHUB_WORKSPACE/apps/cli/src/legacy/main.ts' functions deploy myfn --project-ref abcdefghijklmnopqrst --use-docker" - - - name: "Scenario 3: slim Deno-cache volume is seeded and writable by uid 65532" - run: | - set -euxo pipefail - docker volume ls --format '{{.Name}}' | grep -x supabase_edge_runtime_slim_proj - docker run --rm -v supabase_edge_runtime_slim_proj:/probe \ - --entrypoint /usr/bin/sh "$IMAGE" -c ' - set -eux - bb=$(command -v busybox || echo /usr/bin/busybox) - [ "$($bb stat -c %u /probe)" = "65532" ] - $bb ls -la /probe - $bb touch /probe/.write-probe - ' - - - name: "Scenario 4: docker.io baseline is unchanged (direct bind write as root)" - run: | - set -euxo pipefail - export SUPABASE_ACCESS_TOKEN="sbp_$(printf 'a%.0s' $(seq 40))" - export SUPABASE_PROFILE="$RUNNER_TEMP/profile.yaml" - cd "$RUNNER_TEMP/proj" - sudo rm -rf supabase/functions/myfn - bun "$GITHUB_WORKSPACE/apps/cli/src/legacy/main.ts" functions download myfn \ - --project-ref abcdefghijklmnopqrst --use-docker - sudo test -f supabase/functions/myfn/index.ts - docker volume ls --format '{{.Name}}' | grep -x supabase_edge_runtime_proj - - - name: Mock API log - if: always() - run: cat "$RUNNER_TEMP/mock-api.log" || true From ac136f0cfa62e4b4dda47f532b9bd8f2132d9220 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 28 Aug 2026 11:53:51 +0200 Subject: [PATCH 23/24] fix(cli): refuse leftover slim volumes before create and pin the edge-runtime tag A missing Postgres volume used to mark the run fresh, so leftover docker.io data was pruned on rollback. Probe those volumes first, skip freshness on slim --from-backup, and take the Dockerfile tag from the last colon so slim GHCR refs do not resolve to the registry host. Co-authored-by: Cursor --- .../db/start/start.integration.test.ts | 1 + .../legacy/commands/start/start.handler.ts | 45 ++-- .../commands/start/start.integration.test.ts | 51 +++- .../start/start.slim-images.e2e.test.ts | 245 ++++++++++++++++++ .../shared/db-bootstrap/start-database.ts | 27 +- .../src/shared/functions/functions.shared.ts | 13 +- .../functions/functions.shared.unit.test.ts | 25 +- apps/cli/src/shared/services/slim-images.ts | 2 +- 8 files changed, 361 insertions(+), 48 deletions(-) create mode 100644 apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts 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 bfcc2cf597..2113211578 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 @@ -702,6 +702,7 @@ describe("legacy db start", () => { } } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + expect(volumePruneWasAttempted(child.spawned)).toBe(false); }); }, ); diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 6f75d7ad41..9e9c77526e 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -1534,6 +1534,31 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // doc comment) tees its own stderr. const bringUpDebug = yield* LegacyDebugFlag; + // Leftover storage after a docker.io stop that kept volumes must be + // refused before StartDatabase marks a missing Postgres volume as fresh; + // otherwise rollback would prune the leftover data. + const storagePlanImage = imagePlan.find((entry) => entry.service === "storage")?.image; + if (storagePlanImage !== undefined && legacyUsesSlimRuntime(resolveImage(storagePlanImage))) { + const storageVolumeExisted = yield* legacyVolumeExists(spawner, storageContainerName); + if (storageVolumeExisted) { + const writable = yield* legacyIsVolumeWritableByUid( + spawner, + resolveImage(postgresImage), + storageContainerName, + 65532, + ); + if (!writable) { + return yield* Effect.fail( + new LegacySlimImageVolumeInaccessibleError({ + message: + "the existing storage volume was initialized by a non-slim storage image and is not writable by the slim image's user", + suggestion: `Run ${legacyAqua("supabase stop --no-backup")} to reset the local storage volume, or unset SUPABASE_USE_SLIM_IMAGES.`, + }), + ); + } + } + } + // Runs the DB bootstrap sequence (network -> volume probe -> container // create+start -> health wait -> fresh-volume setup -> `_current_branch`) — shared // with `db start`'s own native container bootstrap, see `legacyStartDatabase`'s own @@ -1798,26 +1823,6 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // this handler already produces, matching the fail-fast-at-decode // behavior every other field validates with. const resolvedServiceImage = resolveImage(image); - if (entry.service === "storage" && legacyUsesSlimRuntime(resolvedServiceImage)) { - const storageVolumeExisted = yield* legacyVolumeExists(spawner, storageContainerName); - if (storageVolumeExisted) { - const writable = yield* legacyIsVolumeWritableByUid( - spawner, - resolveImage(postgresImage), - storageContainerName, - 65532, - ); - if (!writable) { - return yield* Effect.fail( - new LegacySlimImageVolumeInaccessibleError({ - message: - "the existing storage volume was initialized by a non-slim storage image and is not writable by the slim image's user", - suggestion: `Run ${legacyAqua("supabase stop --no-backup")} to reset the local storage volume, or unset SUPABASE_USE_SLIM_IMAGES.`, - }), - ); - } - } - } const { spec, excludeFromHealthWatch } = yield* buildSpecForService( entry.service, resolvedServiceImage, 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 c48d05eea1..de43fd8935 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Fiber, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { Cause, Effect, Exit, Fiber, Layer, Option, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -50,6 +50,7 @@ import { import { LegacyPgDeltaSslProbe } from "../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LEGACY_START_EXCLUDABLE_KEYS } from "./start.exclude.ts"; import type { LegacyStartFlags } from "./start.command.ts"; +import { LegacySlimImageVolumeInaccessibleError } from "../../shared/db-bootstrap/start-database.ts"; import { legacyStart } from "./start.handler.ts"; import { LEGACY_KONG_LOCAL_TLS_CERT, @@ -256,6 +257,28 @@ function rollbackWasAttempted(spawned: ReadonlyArray): boolean { return spawned.some((s) => s.args[0] === "container" && s.args[1] === "prune"); } +function volumePruneWasAttempted(spawned: ReadonlyArray): boolean { + return spawned.some((s) => s.args[0] === "volume" && s.args[1] === "prune"); +} + +/** Fresh Postgres volume + leftover storage volume; the uid write probe is forced. */ +function leftoverStorageVolumeRoute( + base: (args: ReadonlyArray) => RouteResult, + writable: boolean, + storageVolume: string, +): (args: ReadonlyArray) => RouteResult { + return (args) => { + if (args[0] === "volume" && args[1] === "inspect") { + if ((args[2] ?? "") === storageVolume) return { exitCode: 0 }; + return { exitCode: 1, stderr: [`Error: No such volume: ${args[2] ?? ""}`] }; + } + if (args[0] === "run" && args.includes("--entrypoint")) { + return { exitCode: writable ? 0 : 1 }; + } + return base(args); + }; +} + /** * Stateful default route: only created containers inspect successfully, * mirroring Docker across initial state detection and post-create health waits. @@ -2677,6 +2700,32 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); }); + it.live( + "refuses a leftover docker.io storage volume under slim images before creating any container, and does not prune it", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const storageVolume = legacyServiceContainerName("storage", "demo"); + const { layer, child } = setup({ + route: leftoverStorageVolumeRoute(defaultRoute(), false, storageVolume), + }); + return Effect.gen(function* () { + const exit = yield* legacyStart(flags({ exclude: ["edge-runtime"] })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacySlimImageVolumeInaccessibleError); + if (error instanceof LegacySlimImageVolumeInaccessibleError) { + expect(error.message).toContain("storage volume"); + expect(error.suggestion).toContain("supabase stop --no-backup"); + } + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + expect(rollbackWasAttempted(child.spawned)).toBe(true); + expect(volumePruneWasAttempted(child.spawned)).toBe(false); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }, + ); + it.live( "does not seed a configured bucket on a non-fresh volume, even with storage enabled", () => { diff --git a/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts b/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts new file mode 100644 index 0000000000..30348df88d --- /dev/null +++ b/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts @@ -0,0 +1,245 @@ +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 volumeExists(name: string): Promise { + try { + await execFileAsync("docker", ["volume", "inspect", name]); + return true; + } catch { + return false; + } +} + +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!" }); + }, + ); + + test( + "refuses leftover docker.io volumes without pruning them, then starts after stop --no-backup", + { timeout: START_TIMEOUT_MS * 3 + LIFECYCLE_OVERHEAD_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-slim-leftover-e2e-")); + const projectId = legacySanitizeProjectId(path.basename(projectDir)); + const dbVolume = localDbContainerId(projectId); + const storageVolume = legacyServiceContainerName("storage", projectId); + + const init = await runSupabase(["init"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, + env: DOCKER_IO_ENV, + }); + requireCliSuccess(init, "init"); + await overrideStackPorts(projectDir); + + const dockerIoStart = await runSupabase(START_ARGS, { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + env: DOCKER_IO_ENV, + }); + expect( + dockerIoStart.exitCode, + `stdout:\n${dockerIoStart.stdout}\nstderr:\n${dockerIoStart.stderr}`, + ).toBe(0); + + const stopped = await runSupabase(["stop"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, + env: DOCKER_IO_ENV, + }); + requireCliSuccess(stopped, "stop keep volumes"); + expect(await volumeExists(dbVolume)).toBe(true); + expect(await volumeExists(storageVolume)).toBe(true); + + await execFileAsync("docker", ["volume", "rm", dbVolume]); + expect(await volumeExists(dbVolume)).toBe(false); + + const refused = await runSupabase(START_ARGS, { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + env: SLIM_ENV, + }); + expect(refused.exitCode).not.toBe(0); + expect(refused.stderr).toContain("storage volume"); + expect(refused.stderr).toContain("supabase stop --no-backup"); + expect(await volumeExists(storageVolume)).toBe(true); + expect(await volumeExists(dbVolume)).toBe(false); + + const reset = await runSupabase(["stop", "--no-backup"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, + env: SLIM_ENV, + }); + requireCliSuccess(reset, "stop --no-backup"); + expect(await volumeExists(storageVolume)).toBe(false); + + const slimStart = await runSupabase(START_ARGS, { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + env: SLIM_ENV, + }); + expect(slimStart.exitCode, `stdout:\n${slimStart.stdout}\nstderr:\n${slimStart.stderr}`).toBe( + 0, + ); + }, + ); +}); 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 8f166320e2..6087331c84 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -217,10 +217,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; } @@ -257,13 +256,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", @@ -272,11 +270,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 @@ -286,8 +281,10 @@ export const legacyStartDatabase = ( const resolvedPostgresImage = yield* input.resolvePostgresImage; - // Gated on the resolved ref, not the env flag alone: a registry override can still land this - // run on a docker.io image, which restores fine. + // Slim restore has no entrypoint. Refuse before publishing freshness so + // rollback cannot prune leftover sibling volumes from a prior image family. + // Gated on the resolved ref, not the env flag: a registry override can + // still land this run on a docker.io image, which restores fine. if (fromBackup !== undefined && legacyIsSlimPostgresImage(resolvedPostgresImage)) { return yield* Effect.fail( new LegacySlimImagesBackupUnsupportedError({ @@ -297,6 +294,8 @@ export const legacyStartDatabase = ( ); } + input.onFreshVolumeResolved(isFreshVolume); + // A reused volume's PGDATA ownership is a property of whichever image initialized it, not // of the image resolved for THIS run — a docker.io-initialized volume's `700`-mode dirs // block the slim image's non-root user. Only reachable on an existing volume; a fresh one diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 553b8bd94f..243158dd36 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -1,11 +1,8 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { Effect } from "effect"; -import { - dockerfileServiceImage, - dockerfileServiceImageRaw, -} from "../services/dockerfile-images.ts"; -import { slimImageForCurrentPin } from "../services/slim-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_-]*$/; @@ -31,10 +28,6 @@ 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. -// Read per call, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is -// observed by every resolution (and by tests that stub the env). -const defaultEdgeRuntimeImage = () => dockerfileServiceImage("edgeruntime"); - // Go's `deno1` image tag (`pkg/config/constants.go:15`, // `supabase/edge-runtime:v1.68.4`) — a full tag, since tags flow verbatim // into `edgeRuntimeImage` with no `v` synthesis. Shared with @@ -86,6 +79,6 @@ export const resolveEdgeRuntimeVersionPin = Effect.fnUntraced(function* (supabas ).pipe( Effect.map((version) => version.trim()), Effect.catch(() => Effect.succeed("")), - Effect.map((version) => version || (defaultEdgeRuntimeImage().split(":")[1] ?? "")), + 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 index 1ef666caf7..72d94bd4ac 100644 --- a/apps/cli/src/shared/functions/functions.shared.unit.test.ts +++ b/apps/cli/src/shared/functions/functions.shared.unit.test.ts @@ -1,9 +1,15 @@ 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 } from "./functions.shared.ts"; +import { + DENO1_EDGE_RUNTIME_VERSION, + edgeRuntimeImage, + resolveEdgeRuntimeVersionPin, +} from "./functions.shared.ts"; -const currentEdgeRuntimeTag = dockerfileServiceImageRaw("edgeruntime").split(":")[1] ?? ""; +const rawEdgeRuntimeImage = dockerfileServiceImageRaw("edgeruntime"); +const currentEdgeRuntimeTag = rawEdgeRuntimeImage.slice(rawEdgeRuntimeImage.lastIndexOf(":") + 1); afterEach(() => { vi.unstubAllEnvs(); @@ -36,3 +42,18 @@ describe("edgeRuntimeImage", () => { ); }); }); + +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/services/slim-images.ts b/apps/cli/src/shared/services/slim-images.ts index 6698dee926..18a7814af9 100644 --- a/apps/cli/src/shared/services/slim-images.ts +++ b/apps/cli/src/shared/services/slim-images.ts @@ -66,7 +66,7 @@ export function slimImageForAlias(alias: string, image: string): string { return slimImagesEnabled() ? toSlimImage(alias, image) : image; } -function imageTag(image: string): string | undefined { +export function imageTag(image: string): string | undefined { const tagSeparator = image.lastIndexOf(":"); return tagSeparator === -1 ? undefined : image.slice(tagSeparator + 1); } From be0af52024c5370934f8bfbfa298231f6da6cf11 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 28 Aug 2026 18:05:24 +0200 Subject: [PATCH 24/24] fix(cli): drop slim identity forks now that published images match docker.io Rebuilt ghcr postgres/storage/auth/edge-runtime pins start as root, ship sh/wget, and restore after bundle migrate. The flag is only an image-name rewrite; leftover 65532/volume/boot/--from-backup/dump-bash/migrate forks are gone. Storage pin is v1.72.1 to match the rebuilt slim tag. Co-authored-by: Cursor --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- .../legacy/commands/db/start/SIDE_EFFECTS.md | 15 +- .../db/start/start.integration.test.ts | 125 ++------- .../commands/functions/deploy/SIDE_EFFECTS.md | 6 +- .../functions/download/SIDE_EFFECTS.md | 17 +- .../download/download.integration.test.ts | 252 ------------------ .../commands/functions/serve/SIDE_EFFECTS.md | 7 +- .../src/legacy/commands/start/SIDE_EFFECTS.md | 31 +-- .../edge-runtime.service.integration.test.ts | 52 +--- .../start/services/storage.service.ts | 44 ++- .../services/storage.service.unit.test.ts | 13 +- .../legacy/commands/start/start.handler.ts | 52 +--- .../commands/start/start.integration.test.ts | 74 +---- .../start/start.slim-images.e2e.test.ts | 85 ------ .../db-bootstrap/container-lifecycle.ts | 117 -------- .../container-lifecycle.unit.test.ts | 40 --- .../legacy/shared/db-bootstrap/db-setup.ts | 15 +- .../shared/db-bootstrap/db-setup.unit.test.ts | 9 +- .../shared/db-bootstrap/health-check.ts | 16 -- .../db-bootstrap/health-check.unit.test.ts | 73 ----- .../shared/db-bootstrap/postgres.service.ts | 218 +++------------ .../postgres.service.unit.test.ts | 142 ---------- .../shared/db-bootstrap/slim-runtime.ts | 13 +- .../shared/db-bootstrap/start-database.ts | 94 +------ .../shared/legacy-edge-runtime-image.ts | 25 +- .../legacy-edge-runtime-image.unit.test.ts | 20 +- ...e-runtime-script.layer.integration.test.ts | 6 +- .../src/legacy/shared/legacy-pg-dump.run.ts | 11 +- .../shared/legacy-pg-dump.run.unit.test.ts | 6 +- apps/cli/src/shared/functions/deploy.ts | 21 +- .../src/shared/functions/deploy.unit.test.ts | 14 +- apps/cli/src/shared/functions/download.ts | 188 +------------ .../src/shared/functions/functions-docker.ts | 113 +------- .../functions/functions-docker.unit.test.ts | 74 +---- apps/cli/src/shared/functions/serve.errors.ts | 24 -- apps/cli/src/shared/functions/serve.ts | 33 +-- apps/cli/src/shared/services/slim-images.ts | 2 +- 37 files changed, 192 insertions(+), 1857 deletions(-) delete mode 100644 apps/cli/src/shared/functions/serve.errors.ts diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 13c75dcc78..eed4e7da46 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -12,7 +12,7 @@ FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.10 AS supavisor FROM supabase/gotrue:v2.196.0 AS gotrue FROM supabase/realtime:v2.129.3 AS realtime -FROM supabase/storage-api:v1.71.0 AS storage +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 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 17b098db6e..09c3967fca 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -31,15 +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` changes the container's shape (never its volume, published - port, healthcheck, or labels): the image's own entrypoint is kept instead of a `sh -c` - heredoc script, `[db.settings]` travels as trailing `-c key=value` argv rather than a - `postgresql.conf` append, and `/etc/postgresql.schema.sql` plus the pgsodium root key are - `docker cp`'d in before start (the slim image's bundled `migrate.sh` runs that schema file - once, at initdb, exactly like the docker.io image does). `--from-backup` combined with a - resolved slim image is refused here instead — the restore entrypoint has no slim - equivalent. A historical `.temp/postgres-version` pin stays on docker.io; only the - current Dockerfile pin is slim-translated. + `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 @@ -48,8 +42,7 @@ composition reuses too — see that command's `SIDE_EFFECTS.md`): 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; slim Realtime still - runs its one-shot so user migrations see the tenant; slim Storage's one-shot uses the - docker.io storage image), API-privilege + 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 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 2113211578..0fd0dea3e6 100644 --- a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -21,10 +21,6 @@ import { legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { - LegacySlimImagesBackupUnsupportedError, - LegacySlimImageVolumeInaccessibleError, -} from "../../../shared/db-bootstrap/start-database.ts"; import { LegacyDebugFlag, LegacyExperimentalFlag, @@ -233,27 +229,6 @@ function runningCheckFailsRoute( }; } -/** Overrides the default route's answer to the slim-image reused-volume access probe - * (`docker run --rm --entrypoint /usr/bin/sh -v :/probe -c "test -r - * /probe/PG_VERSION && test -w /probe"`) so a test can force it accessible/inaccessible - * without a real container. */ -function slimVolumeProbeRoute( - base: (args: ReadonlyArray) => RouteResult, - accessible: boolean, -): (args: ReadonlyArray) => RouteResult { - return (args) => { - if (args[0] === "run" && args.includes("--entrypoint")) { - return { exitCode: accessible ? 0 : 1 }; - } - return base(args); - }; -} - -/** Whether the slim-image reused-volume access probe (see {@link slimVolumeProbeRoute}) ran. */ -function slimVolumeProbeWasRun(spawned: ReadonlyArray): boolean { - return spawned.some((s) => s.args[0] === "run" && s.args.includes("--entrypoint")); -} - const alwaysReadyHttpClientLayer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -678,87 +653,6 @@ describe("legacy db start", () => { }, ); - // The restore path is entirely a docker.io entrypoint feature, so the slim - // image is refused rather than silently starting an empty cluster. - it.live( - "--from-backup under SUPABASE_USE_SLIM_IMAGES is refused before any container is created", - () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); - return Effect.gen(function* () { - const exit = yield* legacyDbStart(flags("/abs/host/backup.sql")).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(LegacySlimImagesBackupUnsupportedError); - if (error instanceof LegacySlimImagesBackupUnsupportedError) { - expect(error.message).toBe( - "--from-backup is not supported with SUPABASE_USE_SLIM_IMAGES", - ); - expect(error.suggestion).toContain("Unset SUPABASE_USE_SLIM_IMAGES"); - } - } - expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); - expect(volumePruneWasAttempted(child.spawned)).toBe(false); - }); - }, - ); - - // A reused volume's PGDATA ownership is a property of whichever image initialized it: a - // docker.io-initialized volume's `700`-mode dirs (owned by that image's `postgres` uid) block - // the slim image's non-root `65532` user, crash-looping until the health check times out with - // no useful message. `legacyIsVolumeAccessibleToImage` probes for this before any container is - // created. - it.live( - "SUPABASE_USE_SLIM_IMAGES against an existing volume inaccessible to the slim image fails before any container is created", - () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const { layer, child } = setup({ route: slimVolumeProbeRoute(defaultRoute(), false) }); - return Effect.gen(function* () { - const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(LegacySlimImageVolumeInaccessibleError); - if (error instanceof LegacySlimImageVolumeInaccessibleError) { - expect(error.message).toContain("not readable and writable"); - expect(error.suggestion).toContain("supabase stop --no-backup"); - expect(error.suggestion).toContain("SUPABASE_USE_SLIM_IMAGES"); - } - } - expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); - }); - }, - ); - - it.live( - "SUPABASE_USE_SLIM_IMAGES against an existing volume accessible to the slim image proceeds to create the container", - () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const { layer, child, out } = setup({ route: slimVolumeProbeRoute(defaultRoute(), true) }); - return Effect.gen(function* () { - yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(slimVolumeProbeWasRun(child.spawned)).toBe(true); - expect(createArgs(child.spawned)).not.toBeUndefined(); - expect(out.stderrText).toContain("Starting database from backup...\n"); - }); - }, - ); - - it.live( - "no SUPABASE_USE_SLIM_IMAGES: an existing volume never runs the slim-image readability probe", - () => { - const { layer, child } = setup(); - return Effect.gen(function* () { - yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(slimVolumeProbeWasRun(child.spawned)).toBe(false); - }); - }, - ); - it.live( '--from-backup against an existing volume fails with "backup volume already exists" and rolls back without creating a container', () => { @@ -789,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 163b3aa307..8065f527a7 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md @@ -33,11 +33,7 @@ Docker bundling may pull or run the configured edge-runtime image and uses the `supabase_edge_runtime_` Deno cache volume (mounted at -`/root/.cache/deno`). Under `SUPABASE_USE_SLIM_IMAGES` it uses the slim-only -`supabase_edge_runtime_slim_` volume instead, mounted over the slim -image's `/home/nonroot` home so uid 65532's `$HOME`-relative deno/npm caches -persist — a docker.io-seeded volume is root-owned and unwritable by that uid, -so the two families never share a name. +`/root/.cache/deno`). ## API Routes 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 5a713bc879..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,15 +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); the Deno-cache volume is `supabase_edge_runtime_` (mounted at `/root/.cache/deno`), or the slim-only `supabase_edge_runtime_slim_` (mounted over `/home/nonroot`) under `SUPABASE_USE_SLIM_IMAGES` — a docker.io-seeded volume is root-owned and unwritable by the slim image's uid 65532, so the families never share a name | -| `docker run --rm ... --label com.supabase.cli.project= --label com.docker.compose.project= unbundle --eszip ... --output ...` | Docker-unbundle path, docker.io image | extract the downloaded eszip into `supabase/functions//...`; labeled so orphaned containers can be associated with the project | -| `docker create --name supabase_unbundle__ ...` / `docker start --attach` / `docker cp :/tmp/unbundle/ -` / `docker rm --force` | Docker-unbundle path, slim image (`SUPABASE_USE_SLIM_IMAGES`) | the slim image runs as uid 65532 and cannot write into a host `supabase/functions` bind, so the extraction stays container-local (`/tmp/unbundle/`; eszip bound read-only at `/tmp/eszips/`) and is copied out as a tar stream, written into `supabase/functions//...` by the CLI process as the invoking user; the container is force-removed on every path out, matching `--rm` | -| `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 diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index 4f37456c33..04fa4c2b0f 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { afterEach, vi } from "vitest"; import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; -import { containerArchiveBytes } from "../../../../shared/functions/functions-docker.ts"; import { existsSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; @@ -2301,253 +2299,3 @@ describe("legacy functions download", () => { }); }); }); - -/** - * Spawner for the slim (uid 65532) docker-unbundle lifecycle: `create` → - * `start --attach` → `cp :… -` → `rm --force`. Every step exits 0 - * unless overridden; the copy-out step's stdout is the RAW tar bytes of - * `copyOutArchive` — the shared `mockChildProcessSpawner` can't model that - * (it emits newline-terminated text lines, which corrupts a tar stream). - */ -function mockSlimDockerLifecycle( - opts: { - /** Read at spawn time, so a test can fill it inside its own Effect. */ - readonly copyOut?: { archive?: Uint8Array }; - readonly startExitCode?: number; - readonly startStderr?: ReadonlyArray; - } = {}, -) { - const spawned: Array<{ command: string; args: ReadonlyArray }> = []; - const encoder = new TextEncoder(); - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - const cmd = command._tag === "StandardCommand" ? command.command : ""; - const args = command._tag === "StandardCommand" ? command.args : []; - spawned.push({ command: cmd, args }); - - const isStart = args[0] === "start"; - const isCopyOut = args[0] === "cp" && args[2] === "-"; - const exitCode = isStart ? (opts.startExitCode ?? 0) : 0; - const copyOutArchive = opts.copyOut?.archive; - const stdout = - isCopyOut && copyOutArchive !== undefined ? Stream.make(copyOutArchive) : Stream.empty; - const stderr = - isStart && opts.startStderr !== undefined - ? Stream.fromIterable(opts.startStderr.map((line) => encoder.encode(`${line}\n`))) - : Stream.empty; - - const exitDeferred = yield* Deferred.make(); - yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(exitCode)); - - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1000 + spawned.length), - stdout, - stderr, - all: Stream.empty, - exitCode: Deferred.await(exitDeferred), - isRunning: Effect.succeed(false), - stdin: Sink.drain, - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ); - - return { - get spawned() { - return spawned; - }, - layer: Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - }; -} - -describe("legacy functions download — slim images (SUPABASE_USE_SLIM_IMAGES)", () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - function buildSlimLayer(child: { layer: Layer.Layer }) { - const out = mockOutput({ format: "text" }); - const api = mockLegacyPlatformApi(); - const proxy = mockProxy(); - const layer = Layer.mergeAll( - buildLegacyTestRuntime({ - out, - api, - cliSettings: mockLegacyCliSettings({ workdir: tempRoot.current }), - }), - proxy.layer, - child.layer, - Stdio.layerTest({ - args: Effect.succeed([ - "functions", - "download", - "hello-world", - "--use-docker", - "--project-ref", - PROJECT_ID, - ]), - }), - ); - return { out, layer }; - } - - it.live( - "stages the unbundle in the container and copies the files out host-side instead of binding the host functions directory", - () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - // uid 65532 cannot write into a host-owned `supabase/functions` bind on - // native Linux, so the slim flow must never mount it — the extracted - // files arrive via `docker cp` and are written by the CLI process. - const copyOut: { archive?: Uint8Array } = {}; - const child = mockSlimDockerLifecycle({ copyOut }); - const { layer } = buildSlimLayer(child); - - return Effect.gen(function* () { - copyOut.archive = yield* Effect.promise(() => - containerArchiveBytes({ - "hello-world/index.ts": "console.log('from slim unbundle')\n", - "hello-world/nested/util.ts": "export const util = 1;\n", - }), - ); - yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); - - // The slim flow never uses `docker run` — the container must survive - // its own exit for `docker cp` to read the extracted files. - expect(child.spawned.some((spawned) => spawned.args[0] === "run")).toBe(false); - - // Slim runs get their OWN cache volume: a docker.io-seeded - // `supabase_edge_runtime_` volume is root-owned and unwritable by - // uid 65532, so the two families never share a name. - expect(child.spawned.find((spawned) => spawned.args[0] === "volume")).toEqual({ - command: "docker", - args: [ - "volume", - "create", - "--label", - `com.supabase.cli.project=${PROJECT_ID}`, - "--label", - `com.docker.compose.project=${PROJECT_ID}`, - `supabase_edge_runtime_slim_${PROJECT_ID}`, - ], - }); - - const createCommand = child.spawned.find((spawned) => spawned.args[0] === "create"); - expect(createCommand).toBeDefined(); - const containerName = createCommand!.args[2]!; - expect(containerName).toMatch(/^supabase_unbundle_hello-world_[0-9a-f]{8}$/); - - const hostEszipPath = resolve( - tempRoot.current, - "supabase", - ".temp", - "output_hello-world.eszip", - ); - const functionsDir = resolve(tempRoot.current, "supabase", "functions"); - // Cache volume over the nonroot home; eszip bind under /tmp (readable - // by uid 65532 on every slim tag); NO host functions bind. - expect(createCommand!.args).toContain( - `supabase_edge_runtime_slim_${PROJECT_ID}:/home/nonroot:rw`, - ); - expect(createCommand!.args).toContain( - `${hostEszipPath}:/tmp/eszips/output_hello-world.eszip:ro`, - ); - expect(createCommand!.args).not.toContain(`${functionsDir}:/home/deno:rw`); - expect(createCommand!.args.slice(-6)).toEqual([ - // Slim refs skip registry-candidate rewriting, so the resolved image - // is the ghcr.io/supabase/cli reference itself. - dockerfileServiceImage("edgeruntime"), - "unbundle", - "--eszip", - "/tmp/eszips/output_hello-world.eszip", - "--output", - "/tmp/unbundle/hello-world", - ]); - - expect(child.spawned).toContainEqual({ - command: "docker", - args: ["start", "--attach", containerName], - }); - expect(child.spawned).toContainEqual({ - command: "docker", - args: ["cp", `${containerName}:/tmp/unbundle/hello-world`, "-"], - }); - expect(child.spawned).toContainEqual({ - command: "docker", - args: ["rm", "--force", containerName], - }); - - // The extracted files were written host-side, by the CLI process. - expect( - yield* Effect.tryPromise(() => - readFile(join(functionsDir, "hello-world", "index.ts"), "utf8"), - ), - ).toBe("console.log('from slim unbundle')\n"); - expect( - yield* Effect.tryPromise(() => - readFile(join(functionsDir, "hello-world", "nested", "util.ts"), "utf8"), - ), - ).toBe("export const util = 1;\n"); - }).pipe(Effect.provide(layer)); - }, - ); - - it.live( - "still removes the staged container and carries the legacy-bundle suggestion when the unbundle fails", - () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const child = mockSlimDockerLifecycle({ startExitCode: 1, startStderr: ["boom"] }); - const { layer } = buildSlimLayer(child); - - return Effect.gen(function* () { - const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toBe("error running container: exit 1"); - expect((error as Error & { suggestion?: string }).suggestion).toBe( - "\nIf your function is deployed using CLI < 1.120.0, trying running supabase functions download --legacy-bundle hello-world instead.", - ); - - // The failure path must not leak a stopped container — the `rm` here - // is the slim flow's replacement for `docker run --rm`. - const createCommand = child.spawned.find((spawned) => spawned.args[0] === "create"); - const containerName = createCommand!.args[2]!; - expect(child.spawned).toContainEqual({ - command: "docker", - args: ["rm", "--force", containerName], - }); - // No copy is attempted for a failed unbundle. - expect(child.spawned.some((spawned) => spawned.args[0] === "cp")).toBe(false); - }).pipe(Effect.provide(layer)); - }, - ); - - it.live("refuses to extract an archive entry that escapes the functions directory", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - // Entry paths originate from an `unbundle` run over a remote eszip, so a - // crafted path traversing above `supabase/functions` must be rejected - // before anything is written. - const copyOut: { archive?: Uint8Array } = {}; - const child = mockSlimDockerLifecycle({ copyOut }); - const { layer } = buildSlimLayer(child); - - return Effect.gen(function* () { - copyOut.archive = yield* Effect.promise(() => - containerArchiveBytes({ - "hello-world/../../evil.ts": "nope\n", - }), - ); - const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("refusing to extract Function file outside"); - expect(existsSync(join(tempRoot.current, "supabase", "evil.ts"))).toBe(false); - }).pipe(Effect.provide(layer)); - }); -}); 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 ac02168f52..69a2655328 100644 --- a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md @@ -107,16 +107,13 @@ 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_` (mounted at `/root/.cache/deno`), or - `supabase_edge_runtime_slim_` (mounted over `/home/nonroot`) under - `SUPABASE_USE_SLIM_IMAGES` — a docker.io-seeded volume is root-owned and unwritable - by the slim image's uid 65532, so the two families never share a name + - 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`. - Before each container (re)start, resolves the edge-runtime image through the same registry-candidate pull-with-retry every native `functions` Docker path uses: `docker image inspect ` (ECR, then GHCR, then Docker Hub) to check the local cache, then `docker pull ` with 2 retries (4s/8s backoff) on a miss, after `assertLocalDbRunning` — resolving it earlier would hijack the down-daemon error message that DB-inspect step is responsible for producing. - Runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`, same one `start`/`stop`/`status` use) on every startup/restart, before `assertLocalDbRunning` — an invalid config now fails `serve` up front even for fields this command never otherwise reads (e.g. a bad `db.major_version` or malformed auth hook). - A container crash terminates the command with a non-zero exit; only a watched-file change restarts the container — a crashed container is never auto-restarted. -- The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. It is delivered into the created (not yet started) container as a `docker cp` stdin tar archive at `/root/index.ts` (docker.io) or `/tmp/index.ts` with `--main-service=/tmp` on slim images (uid 65532 cannot read `/root`) — never a single-file host bind mount, which materializes as an empty directory on daemons that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, podman machines) and breaks bring-up with edge-runtime's "failed to determine entrypoint" (supabase/cli#6254). Only this bootstrap template is daemon-independent: user function sources, import maps, static files, and the multiline-env script directory (present only when an env value contains a newline) still arrive by host bind mounts, so they require a daemon that can see the project directory. +- The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. It is delivered into the created (not yet started) container as a `docker cp` stdin tar archive at `/root/index.ts` — never a single-file host bind mount, which materializes as an empty directory on daemons that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, podman machines) and breaks bring-up with edge-runtime's "failed to determine entrypoint" (supabase/cli#6254). Only this bootstrap template is daemon-independent: user function sources, import maps, static files, and the multiline-env script directory (present only when an env value contains a newline) still arrive by host bind mounts, so they require a daemon that can see the project directory. - Existing local values declared under an import map's `scopes` are explicit read-only Docker mounts and may resolve outside the nearest Git root; each distinct out-of-root host path prints one `WARN` during bring-up, deduplicated across Functions sharing an import map. Such out-of-root mounts are excluded from the file-watch set per Function, so a scope target contributes no watch root of its own and cannot enlarge or destabilise the watcher; a path that another Function reaches through its ordinary binds is still watched. Other file-valued binds are watched through their immediate parent non-recursively, while directory binds remain recursive. Missing targets retain serve's existing skip behavior. - **Intentional divergence from Go — spec-strict import-map key matching (CLI-2179, ruled 2026-08-12):** bind mounts are computed by the functions import scanner (`walkImportPaths`/`substituteImportMapValue`, shared with `functions deploy` and `start`'s Edge Runtime bring-up), which matches import-map keys per the import-maps spec Deno/edge-runtime implement — exact match, or prefix match only for a `/`-suffixed key — instead of Go's any-key `strings.HasPrefix` (`pkg/function/deno.go:150-155`). Bind mounts may shrink vs the Go CLI for maps that relied on bare-key prefix matching; an unwalkable target (`ENOTDIR` — a value routed through a file) is skipped with a `WARN`. diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 52a1666e35..aeb2ab2f61 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -106,26 +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. -Exception: with `SUPABASE_USE_SLIM_IMAGES` enabled the Postgres container keeps the -slim image's own entrypoint — settings travel as `-c` argv and the bootstrap schema is -delivered via `docker cp` alongside the root key instead (see `db start`'s -SIDE_EFFECTS.md, which documents the slim container shape both commands share). -The same flag also keeps Vector's image entrypoint (`vector --config /etc/vector/vector.yaml`) -and copies `vector.yaml` via `secretFiles` instead of a `sh` heredoc, because the slim Vector -image has no shell-based entrypoint override. Distroless slim services with no `/bin/sh` -(auth, storage, studio, pg-meta) omit Docker healthchecks — `docker create --health-cmd` is -always `CMD-SHELL` — and `legacyCheckContainerReady` treats `Running` as ready; slim Storage -is the exception, gated instead on a `HEAD /storage/v1/status` probe through Kong (the same -gateway shape PostgREST and Edge Runtime use) so bucket seeding below cannot race its -startup. That probe is skipped when Kong itself is `--exclude`d, since nothing then routes -to Storage. Realtime and -analytics keep a busybox `wget --spider` probe; slim Edge Runtime is started without -`--entrypoint sh` (the wrapped binary has no shell) and the main-service template is -copied to `/tmp/index.ts` (`--main-service=/tmp`) because the image runs as uid 65532 -and cannot read `/root`. Slim Storage mounts its named volume at `/mnt` (owned by -uid 65532 in the image). An existing docker.io storage volume is write-probed as -uid 65532 before create; a family mismatch fails with `stop --no-backup` or unset -the flag (same remediation as the slim Postgres volume probe). +`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 @@ -160,8 +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 at `/root/index.ts` (docker.io) or `/tmp/index.ts` (slim, uid 65532 cannot -read `/root`) — a single-file host bind mount materializes as an empty directory on daemons +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 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 bcb51a632c..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 @@ -9,7 +9,6 @@ import { afterEach, beforeEach, vi } from "vitest"; import { useLegacyTempWorkdir } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; -import { SlimEdgeRuntimeMultilineSecretError } from "../../../../shared/functions/serve.errors.ts"; import { legacyStartEdgeRuntimeContainer, type LegacyEdgeRuntimeBringUpInput, @@ -352,7 +351,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { ); it.effect( - "slim edge-runtime: copies the main service to /tmp (uid 65532 cannot read /root) and drops --entrypoint sh", + "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"); @@ -369,21 +368,15 @@ describe("legacyStartEdgeRuntimeContainer", () => { ); const createArgs = mock.runCall!.args; - expect(createArgs).not.toContain("--entrypoint"); - expect(createArgs).toContain("start"); - expect(createArgs).toContain("--main-service=/tmp"); - expect(createArgs).not.toContain("--main-service=/root"); - - // Slim runs ensure their own Deno-cache volume (mounted over the - // nonroot home by the per-function binds): the shared docker.io - // volume mounts at /root/.cache/deno, which uid 65532 neither reaches - // nor uses (Deno caches under $HOME), and a docker.io-seeded volume - // is root-owned anyway. This input serves no functions, so no cache - // bind appears at all — the per-function bind itself is covered by - // `buildDockerBinds`' own slim test (`deploy.unit.test.ts`). + 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_slim_proj"); - expect(createArgs).not.toContain("supabase_edge_runtime_proj:/root/.cache/deno:rw"); + 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:/"]); @@ -397,32 +390,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { return yield* Effect.die("docker cp stdin did not contain archive bytes"); } const files = yield* Effect.promise(() => new Bun.Archive(archiveBytes).files()); - expect([...files.keys()]).toEqual(["tmp/index.ts"]); - }), - ); - - it.effect( - "slim edge-runtime: refuses a multiline secret it has no shell to source, before creating anything", - () => - Effect.gen(function* () { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const mock = mockDockerSpawner(); - const out = mockOutput(); - const input = { - ...baseInput(tempWorkdir.current), - image: "ghcr.io/supabase/cli/edge-runtime:v1.74.2", - edgeRuntimeSecrets: { MULTI_LINE_KEY: "-----BEGIN KEY-----\nsecret\n-----END KEY-----" }, - }; - - const error = yield* legacyStartEdgeRuntimeContainer(input).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(SlimEdgeRuntimeMultilineSecretError); - expect(String(error)).toContain("cannot source multiline function secrets"); - expect(mock.runCall).toBeUndefined(); + expect([...files.keys()]).toEqual(["root/index.ts"]); }), ); 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 cc7da39f8f..55d283180f 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.ts @@ -38,7 +38,6 @@ import type { CliConfig } from "@supabase/config"; import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; -import { legacyUsesSlimRuntime } from "../../../shared/db-bootstrap/slim-runtime.ts"; import { ramInBytes } from "../../../shared/legacy-size-units.ts"; import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; import { @@ -202,14 +201,12 @@ export interface LegacyStorageContainerSpecInput { /** * Builds the `docker create` spec for the Storage container. `binds` mounts * the container's own named volume at `/mnt` — no `ports`/`exposedPorts`, - * Storage is reached only via its Docker network alias. Slim `/mnt` is owned - * by uid 65532 so an empty volume inherits that owner. + * Storage is reached only via its Docker network alias. */ export function legacyBuildStorageContainerSpec( input: LegacyStorageContainerSpecInput, ): LegacyStartContainerSpec { const containerName = legacyServiceContainerName("storage", input.projectId); - const slim = legacyUsesSlimRuntime(input.image); const env = { ...legacyBuildStorageEnv({ targetMigration: input.targetMigration, @@ -236,30 +233,21 @@ export function legacyBuildStorageContainerSpec( containerName, env, binds: [`${containerName}:${LEGACY_STORAGE_DOCKER_PATH}`], - // Distroless slim storage has no /bin/sh (nor wget) and Docker CLI - // healthchecks are always CMD-SHELL, so there is no probe to declare here. - // `start` gates readiness on Kong's `/storage/v1/status` instead — see - // `health-check.ts` — because `legacyCheckContainerReady` would otherwise - // accept a merely-Running container. - ...(slim - ? {} - : { - healthcheck: { - // "For some reason, localhost resolves to IPv6 address on GitPod which breaks - // healthcheck." — IPv4 loopback pinned. - test: [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - "http://127.0.0.1:5000/status", - ], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - }, - }), + healthcheck: { + // "For some reason, localhost resolves to IPv6 address on GitPod which breaks + // healthcheck." — IPv4 loopback pinned. + test: [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:5000/status", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, restartPolicy: "unless-stopped", networkId: input.networkId, // The Storage network alias. diff --git a/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts index 1b94a2d769..9182f13302 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts @@ -258,16 +258,23 @@ describe("legacyBuildStorageContainerSpec", () => { ); }); - test("omits the Docker healthcheck on a slim distroless storage image", () => { + 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).toBeUndefined(); + 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 (uid 65532 owns /mnt)", () => { + test("mounts the named volume at /mnt on a slim image", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = legacyBuildStorageContainerSpec({ ...input, diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 9e9c77526e..07fcea01c1 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -135,15 +135,10 @@ import { legacyRollbackStart, } from "../../shared/db-bootstrap/rollback.ts"; import { legacyResolveDbBootstrapConfig } from "../../shared/db-bootstrap/bootstrap-config.ts"; -import { - LegacySlimImageVolumeInaccessibleError, - legacyStartDatabase, -} from "../../shared/db-bootstrap/start-database.ts"; +import { legacyStartDatabase } from "../../shared/db-bootstrap/start-database.ts"; import { LEGACY_START_SERVICES } from "./start.services.ts"; import { legacyCreateContainer, - legacyIsVolumeWritableByUid, - legacyVolumeExists, type LegacyContainerOpts, } from "../../shared/db-bootstrap/container-lifecycle.ts"; import { legacyEnsureImagesCached } from "../../shared/db-bootstrap/image-prepull.ts"; @@ -183,7 +178,6 @@ import { import { legacyBuildMailpitContainerSpec } from "./services/mailpit.service.ts"; import { legacyBuildRealtimeContainerSpec } from "./services/realtime.service.ts"; import { LEGACY_REALTIME_TENANT_ID } from "../../shared/db-bootstrap/realtime-env.ts"; -import { legacyUsesSlimRuntime } from "../../shared/db-bootstrap/slim-runtime.ts"; import { legacyBuildPostgrestContainerSpec } from "./services/postgrest.service.ts"; import { legacyBuildStorageContainerSpec } from "./services/storage.service.ts"; import { legacyBuildImgproxyContainerSpec } from "./services/imgproxy.service.ts"; @@ -1534,31 +1528,6 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // doc comment) tees its own stderr. const bringUpDebug = yield* LegacyDebugFlag; - // Leftover storage after a docker.io stop that kept volumes must be - // refused before StartDatabase marks a missing Postgres volume as fresh; - // otherwise rollback would prune the leftover data. - const storagePlanImage = imagePlan.find((entry) => entry.service === "storage")?.image; - if (storagePlanImage !== undefined && legacyUsesSlimRuntime(resolveImage(storagePlanImage))) { - const storageVolumeExisted = yield* legacyVolumeExists(spawner, storageContainerName); - if (storageVolumeExisted) { - const writable = yield* legacyIsVolumeWritableByUid( - spawner, - resolveImage(postgresImage), - storageContainerName, - 65532, - ); - if (!writable) { - return yield* Effect.fail( - new LegacySlimImageVolumeInaccessibleError({ - message: - "the existing storage volume was initialized by a non-slim storage image and is not writable by the slim image's user", - suggestion: `Run ${legacyAqua("supabase stop --no-backup")} to reset the local storage volume, or unset SUPABASE_USE_SLIM_IMAGES.`, - }), - ); - } - } - } - // Runs the DB bootstrap sequence (network -> volume probe -> container // create+start -> health wait -> fresh-volume setup -> `_current_branch`) — shared // with `db start`'s own native container bootstrap, see `legacyStartDatabase`'s own @@ -1698,7 +1667,6 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta const started = new Map(); let postgrestGateway: LegacyHealthCheckPostgrestGateway | undefined; let edgeRuntimeGateway: LegacyHealthCheckPostgrestGateway | undefined; - let storageGateway: LegacyHealthCheckPostgrestGateway | undefined; let storageContainerId: string | undefined; const imagePlanByService = new Map(imagePlan.map((entry) => [entry.service, entry.image])); for (const entry of LEGACY_START_SERVICES) { @@ -1848,18 +1816,6 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta } if (entry.service === "storage") { storageContainerId = spec.containerName; - // The slim Storage spec declares no Docker healthcheck (distroless — - // see `storage.service.ts`), so without this gateway the health wait - // would accept it as soon as it is `Running` and bucket seeding below - // would race its startup. Needs Kong to reach it; with Kong excluded - // there is no route to probe, so fall back to the Running-only check. - if (legacyUsesSlimRuntime(spec.image) && gates.kong) { - storageGateway = { - containerId: spec.containerName, - apiExternalUrl: values.apiUrl, - secretKey: values.secretKey, - }; - } } } @@ -1868,7 +1824,6 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta started, postgrestGateway, edgeRuntimeGateway, - storageGateway, storageContainerId, }; }).pipe( @@ -1932,8 +1887,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // but never re-entering these later steps once the DB bootstrap has // already returned. if (bringUpResult.kind === "started") { - const { started, postgrestGateway, edgeRuntimeGateway, storageGateway, storageContainerId } = - bringUpResult; + const { started, postgrestGateway, edgeRuntimeGateway, storageContainerId } = bringUpResult; // Wraps steps 9-11 below (bulk health wait, the ignore-health-check // storage-only recheck-and-seed, the success-path bucket seeding, and @@ -2044,7 +1998,6 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta legacyWaitForHealthyServices(spawner, [...started.keys()], { postgrest: postgrestGateway, edgeRuntime: edgeRuntimeGateway, - storage: storageGateway, images: started, }), ).pipe(Effect.result); @@ -2070,7 +2023,6 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // containers that actually appear in this call's own failures. const storageHealthResult = yield* withLocalKongCa( legacyWaitForHealthyServices(spawner, [storageContainerId], { - storage: storageGateway, images: started, }), ).pipe(Effect.result); 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 de43fd8935..23fbd53b4b 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Fiber, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { Effect, Exit, Fiber, Layer, Option, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -50,7 +50,6 @@ import { import { LegacyPgDeltaSslProbe } from "../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LEGACY_START_EXCLUDABLE_KEYS } from "./start.exclude.ts"; import type { LegacyStartFlags } from "./start.command.ts"; -import { LegacySlimImageVolumeInaccessibleError } from "../../shared/db-bootstrap/start-database.ts"; import { legacyStart } from "./start.handler.ts"; import { LEGACY_KONG_LOCAL_TLS_CERT, @@ -257,28 +256,6 @@ function rollbackWasAttempted(spawned: ReadonlyArray): boolean { return spawned.some((s) => s.args[0] === "container" && s.args[1] === "prune"); } -function volumePruneWasAttempted(spawned: ReadonlyArray): boolean { - return spawned.some((s) => s.args[0] === "volume" && s.args[1] === "prune"); -} - -/** Fresh Postgres volume + leftover storage volume; the uid write probe is forced. */ -function leftoverStorageVolumeRoute( - base: (args: ReadonlyArray) => RouteResult, - writable: boolean, - storageVolume: string, -): (args: ReadonlyArray) => RouteResult { - return (args) => { - if (args[0] === "volume" && args[1] === "inspect") { - if ((args[2] ?? "") === storageVolume) return { exitCode: 0 }; - return { exitCode: 1, stderr: [`Error: No such volume: ${args[2] ?? ""}`] }; - } - if (args[0] === "run" && args.includes("--entrypoint")) { - return { exitCode: writable ? 0 : 1 }; - } - return base(args); - }; -} - /** * Stateful default route: only created containers inspect successfully, * mirroring Docker across initial state detection and post-create health waits. @@ -2677,55 +2654,6 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(layer)); }); - it.live("probes storage's own /status before seeding a bucket on the slim image", () => { - // The slim Storage spec carries no Docker healthcheck, so without the - // gateway probe seeding would fire the moment the container is Running. - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const http = mockStorageBucketHttpClient(); - const { layer } = setup({ - configContents: 'project_id = "demo"\n[storage.buckets.avatars]\npublic = false\n', - route: freshVolumeRoute(defaultRoute()), - httpClientLayer: http.layer, - }); - return Effect.gen(function* () { - yield* legacyStart(flags({ exclude: ["edge-runtime"] })); - const probeIndex = http.requests.findIndex( - (entry) => entry.method === "HEAD" && entry.url.includes("/storage/v1/status"), - ); - const seedIndex = http.requests.findIndex( - (entry) => entry.method === "POST" && entry.url.includes("/storage/v1/bucket"), - ); - expect(probeIndex).toBeGreaterThanOrEqual(0); - expect(seedIndex).toBeGreaterThan(probeIndex); - }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); - }); - - it.live( - "refuses a leftover docker.io storage volume under slim images before creating any container, and does not prune it", - () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const storageVolume = legacyServiceContainerName("storage", "demo"); - const { layer, child } = setup({ - route: leftoverStorageVolumeRoute(defaultRoute(), false, storageVolume), - }); - return Effect.gen(function* () { - const exit = yield* legacyStart(flags({ exclude: ["edge-runtime"] })).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(LegacySlimImageVolumeInaccessibleError); - if (error instanceof LegacySlimImageVolumeInaccessibleError) { - expect(error.message).toContain("storage volume"); - expect(error.suggestion).toContain("supabase stop --no-backup"); - } - } - expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); - expect(rollbackWasAttempted(child.spawned)).toBe(true); - expect(volumePruneWasAttempted(child.spawned)).toBe(false); - }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); - }, - ); - it.live( "does not seed a configured bucket on a non-fresh volume, even with storage enabled", () => { diff --git a/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts b/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts index 30348df88d..96546a0360 100644 --- 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 @@ -61,16 +61,6 @@ function readSectionPort(config: string, section: string): number { } return Number(match[1]); } - -async function volumeExists(name: string): Promise { - try { - await execFileAsync("docker", ["volume", "inspect", name]); - return true; - } catch { - return false; - } -} - async function containerImage(name: string): Promise { const { stdout } = await execFileAsync("docker", [ "inspect", @@ -167,79 +157,4 @@ describe("supabase start slim images (e2e)", () => { expect(JSON.parse(body)).toEqual({ message: "Hello Functions!" }); }, ); - - test( - "refuses leftover docker.io volumes without pruning them, then starts after stop --no-backup", - { timeout: START_TIMEOUT_MS * 3 + LIFECYCLE_OVERHEAD_MS }, - async () => { - projectDir = await mkdtemp(path.join(tmpdir(), "sb-slim-leftover-e2e-")); - const projectId = legacySanitizeProjectId(path.basename(projectDir)); - const dbVolume = localDbContainerId(projectId); - const storageVolume = legacyServiceContainerName("storage", projectId); - - const init = await runSupabase(["init"], { - entrypoint: "legacy", - cwd: projectDir, - exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, - env: DOCKER_IO_ENV, - }); - requireCliSuccess(init, "init"); - await overrideStackPorts(projectDir); - - const dockerIoStart = await runSupabase(START_ARGS, { - entrypoint: "legacy", - cwd: projectDir, - exitTimeoutMs: START_TIMEOUT_MS, - env: DOCKER_IO_ENV, - }); - expect( - dockerIoStart.exitCode, - `stdout:\n${dockerIoStart.stdout}\nstderr:\n${dockerIoStart.stderr}`, - ).toBe(0); - - const stopped = await runSupabase(["stop"], { - entrypoint: "legacy", - cwd: projectDir, - exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, - env: DOCKER_IO_ENV, - }); - requireCliSuccess(stopped, "stop keep volumes"); - expect(await volumeExists(dbVolume)).toBe(true); - expect(await volumeExists(storageVolume)).toBe(true); - - await execFileAsync("docker", ["volume", "rm", dbVolume]); - expect(await volumeExists(dbVolume)).toBe(false); - - const refused = await runSupabase(START_ARGS, { - entrypoint: "legacy", - cwd: projectDir, - exitTimeoutMs: START_TIMEOUT_MS, - env: SLIM_ENV, - }); - expect(refused.exitCode).not.toBe(0); - expect(refused.stderr).toContain("storage volume"); - expect(refused.stderr).toContain("supabase stop --no-backup"); - expect(await volumeExists(storageVolume)).toBe(true); - expect(await volumeExists(dbVolume)).toBe(false); - - const reset = await runSupabase(["stop", "--no-backup"], { - entrypoint: "legacy", - cwd: projectDir, - exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, - env: SLIM_ENV, - }); - requireCliSuccess(reset, "stop --no-backup"); - expect(await volumeExists(storageVolume)).toBe(false); - - const slimStart = await runSupabase(START_ARGS, { - entrypoint: "legacy", - cwd: projectDir, - exitTimeoutMs: START_TIMEOUT_MS, - env: SLIM_ENV, - }); - expect(slimStart.exitCode, `stdout:\n${slimStart.stdout}\nstderr:\n${slimStart.stderr}`).toBe( - 0, - ); - }, - ); }); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts index de7c16e948..8a1dc49dd5 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts @@ -489,123 +489,6 @@ export function legacyVolumeExists( ); } -/** - * Whether an EXISTING database volume is accessible (readable AND writable) to the given (slim) - * Postgres image's own user, via `docker run --rm --entrypoint /usr/bin/sh -v :/probe - * -c "test -r /probe/PG_VERSION && test -w /probe"` — cheaper than a real bring-up - * attempt, and runs before any db container is created. Read alone is not enough: Postgres must - * write `postmaster.pid`/WAL under PGDATA, so a read-only-accessible volume would still - * crash-loop past this guard. The script exits `0` when accessible, `1` when not (e.g. a - * docker.io-initialized volume's `700`-mode PGDATA dirs, owned by that image's postgres uid, - * blocking the slim image's non-root `65532`). Any OTHER exit (spawn failure, or Docker's own - * `docker run` convention of `125`/`126`/`127` for a daemon/exec-level problem rather than the - * probed command's own exit) propagates as a genuine docker-run failure instead of being folded - * into the `1` case. - */ -export function legacyIsVolumeAccessibleToImage( - spawner: Spawner, - image: string, - name: string, -): Effect.Effect { - const fail = (message: string): LegacyContainerCreateError => - new LegacyContainerCreateError({ message, reason: "runtime" }); - return Effect.scoped( - Effect.gen(function* () { - const child = yield* spawnContainerCli( - spawner, - [ - "run", - "--rm", - "--entrypoint", - "/usr/bin/sh", - "-v", - `${name}:/probe`, - image, - "-c", - "test -r /probe/PG_VERSION && test -w /probe", - ], - { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, - ).pipe( - Effect.mapError((cause) => - fail( - `failed to probe database volume access: ${legacyDescribeContainerCliFailure(cause)}`, - ), - ), - ); - const [exitCode, stderr] = yield* Effect.all( - [child.exitCode.pipe(Effect.map(Number)), legacyCollectText(child.stderr)], - { concurrency: "unbounded" }, - ).pipe(Effect.mapError(() => fail("failed to probe database volume access"))); - if (exitCode === 0) return true; - if (exitCode === 1) return false; - const message = stderr.trim(); - return yield* Effect.fail( - fail( - message.length > 0 - ? `failed to probe database volume access: ${message}` - : `failed to probe database volume access: exit ${exitCode}`, - ), - ); - }), - ); -} - -/** - * Write probe for an existing named volume as `uid` (slim storage is - * distroless — do not exec in that image). Use an image that has `sh` (the - * already-resolved postgres image, same uid 65532). `/bin/sh` exists on both - * slim and docker.io postgres; `/usr/bin/sh` does not. Exit `0` writable, - * `1` not; other exits fail. - */ -export function legacyIsVolumeWritableByUid( - spawner: Spawner, - probeImage: string, - name: string, - uid: number, -): Effect.Effect { - const fail = (message: string): LegacyContainerCreateError => - new LegacyContainerCreateError({ message, reason: "runtime" }); - return Effect.scoped( - Effect.gen(function* () { - const child = yield* spawnContainerCli( - spawner, - [ - "run", - "--rm", - "--user", - String(uid), - "--entrypoint", - "/bin/sh", - "-v", - `${name}:/probe`, - probeImage, - "-c", - "test -w /probe", - ], - { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, - ).pipe( - Effect.mapError((cause) => - fail(`failed to probe volume access: ${legacyDescribeContainerCliFailure(cause)}`), - ), - ); - const [exitCode, stderr] = yield* Effect.all( - [child.exitCode.pipe(Effect.map(Number)), legacyCollectText(child.stderr)], - { concurrency: "unbounded" }, - ).pipe(Effect.mapError(() => fail("failed to probe volume access"))); - if (exitCode === 0) return true; - if (exitCode === 1) return false; - const message = stderr.trim(); - return yield* Effect.fail( - fail( - message.length > 0 - ? `failed to probe volume access: ${message}` - : `failed to probe volume access: exit ${exitCode}`, - ), - ); - }), - ); -} - /** `docker container rm -f ` (or `docker rm -f`) failed. */ export class LegacyContainerRemoveError extends Data.TaggedError("LegacyContainerRemoveError")<{ readonly message: string; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts index 3d65181f7e..437b21a4c2 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts @@ -20,7 +20,6 @@ import { legacyRemoveContainer, legacyRemoveVolume, legacyCreateContainer, - legacyIsVolumeWritableByUid, legacyVolumeExists, } from "./container-lifecycle.ts"; import type { LegacyStartContainerSpec } from "./docker-create-args.ts"; @@ -896,42 +895,3 @@ describe("legacyCreateContainer with an empty containerName (the shadow database }, ); }); - -describe("legacyIsVolumeWritableByUid", () => { - it.live("probes as the given uid with sh, not the storage image", () => { - const mock = mockSpawner(() => ({ exitCode: 0 })); - return legacyIsVolumeWritableByUid( - mock.spawner, - "ghcr.io/supabase/cli/postgres:17.6.1.166", - "supabase_storage_proj", - 65532, - ).pipe( - Effect.map((writable) => { - expect(writable).toBe(true); - expect(mock.spawned[0]).toEqual([ - "run", - "--rm", - "--user", - "65532", - "--entrypoint", - "/bin/sh", - "-v", - "supabase_storage_proj:/probe", - "ghcr.io/supabase/cli/postgres:17.6.1.166", - "-c", - "test -w /probe", - ]); - }), - ); - }); - - it.live("treats exit 1 as not writable", () => { - const mock = mockSpawner(() => ({ exitCode: 1 })); - return legacyIsVolumeWritableByUid( - mock.spawner, - "ghcr.io/supabase/cli/postgres:17.6.1.166", - "supabase_storage_proj", - 65532, - ).pipe(Effect.map((writable) => expect(writable).toBe(false))); - }); -}); 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 fa9ae83854..bad140e026 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -162,10 +162,8 @@ import { type LegacyVaultSecret, legacyUpsertVaultSecrets, } from "../legacy-vault.ts"; -import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; import { legacyEnsureImagesCached, type LegacyImagePrepullError } from "./image-prepull.ts"; import { legacyResolvePinnedImage } from "./pinned-image.ts"; -import { legacyUsesSlimRuntime } from "./slim-runtime.ts"; import { LEGACY_COMPOSE_PROJECT_LABEL } from "./container-lifecycle.ts"; import { LEGACY_REALTIME_TENANT_ID, legacyBuildRealtimeEnv } from "./realtime-env.ts"; import { LEGACY_START_DB_GLOBALS_SQL } from "./templates/db-globals.sql.ts"; @@ -846,9 +844,6 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( // fix already applied to `resolveDbHealthTimeoutSeconds` and the // long-running Storage container's own file-size-limit parsing // (`start.handler.ts`). - // Slim storage has no `migrate-call.js`. Run the one-shot on the docker.io - // pin so `legacyGetRegistryImageUrl` can rewrite it — same locked - // exception as the pg-delta shell image. const storageEnv = yield* Effect.try({ try: () => legacyStartStorageMigrateEnv({ @@ -867,9 +862,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( }), }); yield* legacyRunStartMigrateJob(spawner, { - image: legacyUsesSlimRuntime(input.images.storage) - ? dockerfileServiceImageRaw("storage") - : input.images.storage, + image: input.images.storage, networkId: input.networkId, projectId: input.projectId, projectEnvValues: input.projectEnvValues, @@ -879,10 +872,6 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( }); } if (input.config.auth.enabled) { - // Slim auth bakes `/usr/local/bin/auth` as ENTRYPOINT, so `["gotrue", "migrate"]` - // would become `auth gotrue migrate`. The docker.io image has an empty - // entrypoint and expects the binary name in argv. - const slimAuth = legacyUsesSlimRuntime(input.images.auth); yield* legacyRunStartMigrateJob(spawner, { image: input.images.auth, networkId: input.networkId, @@ -897,7 +886,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( dbHost, dbPassword, }), - cmd: slimAuth ? ["migrate"] : ["gotrue", "migrate"], + cmd: ["gotrue", "migrate"], }); } }); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts index 6f0a42b60b..abedc69753 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 @@ -9,7 +9,6 @@ import { afterEach, vi } from "vitest"; import { Deferred, Effect, FileSystem, Layer, Path, Schema, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; import { LegacyDbExecError } from "../legacy-db-connection.errors.ts"; import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; @@ -30,8 +29,6 @@ import { type LegacyStartSetupLocalDatabaseInput, } from "./db-setup.ts"; -const currentStorageTag = dockerfileServiceImageRaw("storage").split(":")[1] ?? ""; - const decodeConfig = Schema.decodeUnknownSync(CliConfigSchema); /** @@ -372,7 +369,7 @@ describe("legacyStartSetupLocalDatabase", () => { }); it.effect( - "slim refs: runs realtime one-shot, storage on docker.io, and auth as migrate", + "slim refs: runs realtime, storage, and auth one-shots on the resolved slim images", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const workdir = makeWorkdir(); @@ -394,12 +391,12 @@ describe("legacyStartSetupLocalDatabase", () => { Effect.map(() => { expect(docker.runs.map((job) => job.image)).toEqual([ "ghcr.io/supabase/cli/realtime:v2.129.3", - `public.ecr.aws/supabase/storage-api:${currentStorageTag}`, + "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(["migrate"]); + expect(docker.runs[2]?.cmd).toEqual(["gotrue", "migrate"]); rmSync(workdir, { recursive: true, force: true }); }), ); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts b/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts index 4c3f7a35cf..f791a6f6fe 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts @@ -57,16 +57,6 @@ const LEGACY_POSTGREST_READY_PATH = "/rest-admin/v1/ready"; */ const LEGACY_EDGE_RUNTIME_READY_PATH = "/functions/v1/_internal/health"; -/** - * Storage's own `/status` (the endpoint its docker.io HEALTHCHECK probes with - * `wget --spider`), reached through Kong's `/storage/v1/` route. Only the slim - * image needs it: distroless Storage carries no Docker healthcheck (no shell - * for `CMD-SHELL`, see `slim-runtime.ts`), so {@link legacyCheckContainerReady} - * would report it ready the moment it is `Running` — before it can answer the - * bucket-seeding calls `start` makes right after this gate. - */ -const LEGACY_STORAGE_READY_PATH = "/storage/v1/status"; - /** Identifies a single container's readiness failure this round. */ export interface LegacyHealthCheckFailure { /** @@ -148,8 +138,6 @@ export interface LegacyWaitForHealthyServicesOptions { readonly postgrest?: LegacyHealthCheckPostgrestGateway; /** See {@link LEGACY_EDGE_RUNTIME_READY_PATH}'s doc comment for why this reuses the same gateway shape as {@link postgrest}. */ readonly edgeRuntime?: LegacyHealthCheckPostgrestGateway; - /** Set only for the slim Storage image — see {@link LEGACY_STORAGE_READY_PATH}. */ - readonly storage?: LegacyHealthCheckPostgrestGateway; /** Each watched container's already-resolved image, keyed by container name. */ readonly images?: ReadonlyMap; } @@ -362,7 +350,6 @@ export function legacyWaitForHealthyServices( const timeoutSeconds = opts.timeoutSeconds ?? LEGACY_HEALTH_CHECK_TIMEOUT_SECONDS; const postgrest = opts.postgrest; const edgeRuntime = opts.edgeRuntime; - const storage = opts.storage; const checkOne = (containerId: string): Effect.Effect => { if (postgrest !== undefined && containerId === postgrest.containerId) { @@ -371,9 +358,6 @@ export function legacyWaitForHealthyServices( if (edgeRuntime !== undefined && containerId === edgeRuntime.containerId) { return legacyCheckHttpReady(edgeRuntime, LEGACY_EDGE_RUNTIME_READY_PATH); } - if (storage !== undefined && containerId === storage.containerId) { - return legacyCheckHttpReady(storage, LEGACY_STORAGE_READY_PATH); - } return legacyCheckContainerReady(spawner, containerId); }; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts index 6167c3ac55..731d96751a 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts @@ -151,8 +151,6 @@ const runningStarting = JSON.stringify({ Health: { Status: "starting" }, }); const notRunning = JSON.stringify({ Status: "exited", Running: false }); -/** A container with no Docker healthcheck at all, like every distroless slim image. */ -const runningNoHealth = JSON.stringify({ Status: "running", Running: true }); /** * `legacyWaitForHealthyServices` structurally requires `HttpClient.HttpClient` @@ -655,77 +653,6 @@ describe("legacyWaitForHealthyServices", () => { ); }); - describe("slim Storage HTTP-HEAD readiness", () => { - const storageGateway: LegacyHealthCheckPostgrestGateway = { - containerId: "supabase_storage_proj", - apiExternalUrl: "http://127.0.0.1:54321", - secretKey: "sb_secret_local", - }; - - function httpLayer(status: number) { - return Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => { - expect(request.method).toBe("HEAD"); - expect(request.url).toBe("http://127.0.0.1:54321/storage/v1/status"); - return Effect.succeed( - HttpClientResponse.fromWeb(request, new Response(null, { status })), - ); - }), - ); - } - - it.effect("succeeds on a 200 instead of trusting a merely-Running container", () => - Effect.gen(function* () { - const mock = mockHealthSpawner(() => runningNoHealth); - - const exit = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_storage_proj"], { - timeoutSeconds: 1, - storage: storageGateway, - }).pipe(Effect.provide(httpLayer(200)), Effect.exit); - - expect(Exit.isSuccess(exit)).toBe(true); - expect(inspectCalls(mock)).toHaveLength(0); - }), - ); - - it.effect("keeps waiting while Storage is still starting up", () => - Effect.gen(function* () { - const mock = mockHealthSpawner(() => runningNoHealth); - - const fiber = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_storage_proj"], { - timeoutSeconds: 1, - storage: storageGateway, - }).pipe( - Effect.provide(httpLayer(503)), - withSilencedStderr, - Effect.forkChild({ startImmediately: true }), - ); - - yield* TestClock.adjust("1 seconds"); - const error = yield* Fiber.join(fiber).pipe(Effect.flip); - - expect(error).toBeInstanceOf(LegacyHealthCheckTimeoutError); - expect(error.unhealthy).toEqual([ - { containerId: "supabase_storage_proj", reason: "unexpected status 503" }, - ]); - }), - ); - - it.effect("falls back to the Docker state when no gateway is configured", () => - Effect.gen(function* () { - const mock = mockHealthSpawner(() => runningNoHealth); - - const exit = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_storage_proj"], { - timeoutSeconds: 1, - }).pipe(Effect.provide(unusedHttpClientLayer), Effect.exit); - - expect(Exit.isSuccess(exit)).toBe(true); - expect(inspectCalls(mock)).toHaveLength(1); - }), - ); - }); - describe("Edge Runtime HTTP-HEAD readiness", () => { function edgeRuntimeGateway(secretKey: string): LegacyHealthCheckPostgrestGateway { return { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts index 856a035f67..156d56c6da 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts @@ -22,7 +22,6 @@ import type { CliConfig } from "@supabase/config"; -import { isSlimImageRef, slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { localDbContainerId } from "../legacy-docker-ids.ts"; import { legacyToDockerPath } from "../legacy-docker-path.ts"; import { encodeToml } from "../legacy-go-output.encoders.ts"; @@ -52,13 +51,10 @@ const LEGACY_POSTGRES_PASSWORD = "postgres"; const LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH = "/etc/postgresql-custom/pgsodium_root.key"; /** - * The post-migration hook path both image families read: `supabase/postgres`'s - * bundled `migrate.sh` execs `psql -v ON_ERROR_STOP=1 -U supabase_admin -f - * /etc/postgresql.schema.sql` as its last step when the file exists. The - * docker.io entrypoint heredocs it (see - * {@link legacyPostgresEntrypointScriptPg15}); the slim image's own entrypoint - * has no heredoc seam, so the slim path stages the same bytes at the same path - * via `secretFiles` instead — see {@link legacyPostgresSlimBootFields}. + * 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"; @@ -79,22 +75,6 @@ const LEGACY_POSTGRES_HEALTHCHECK_TEST: ReadonlyArray = [ "5432", ]; -/** - * The slim image's first boot runs initdb + bundled migrations against a TEMPORARY server - * before `entry.sh` execs the final `postgres` process, so PID 1 is still the entrypoint - * shell during that window and a bare `pg_isready` would pass against the temporary - * server. Gate on the final Postgres process the same way `packages/stack`'s own slim - * Docker healthcheck does (`postgresDockerHealthCheck`, - * `packages/stack/src/services/postgres.ts`). - */ -const LEGACY_POSTGRES_SLIM_HEALTHCHECK_TEST: ReadonlyArray = [ - "CMD", - "sh", - "-ec", - // Linux /proc/1/comm truncates `.postgres-wrapped` to 15 characters. - 'case "$(cat /proc/1/comm)" in postgres|.postgres-wrapp) pg_isready -U postgres -h 127.0.0.1 -p 5432 ;; *) exit 1 ;; esac', -]; - /** Go's `utils.DbAliases` (`apps/cli-go/internal/utils/config.go:36`). */ const LEGACY_POSTGRES_NETWORK_ALIASES: ReadonlyArray = ["db", "db.supabase.internal"]; @@ -198,24 +178,6 @@ function legacyDefinedPostgresSettings( ); } -/** - * The same `[db.settings]` source as {@link legacyPostgresSettingsToPostgresConfig}, - * rendered as repeated `-c key=value` argv pairs instead of `postgresql.conf` - * lines. The slim image's entrypoint ends in `exec postgres -D $PGDATA "$@"`, so - * the container's trailing argv reaches the server verbatim — no conf-file append - * seam, and no shell quoting to get right, since each pair travels as its own - * argv element. Values are unquoted on purpose: the TOML renderer's single quotes - * are TOML syntax, whereas `postgres -c` takes the raw value. - */ -export function legacyPostgresSettingsToConfigArgs( - settings: CliConfig["db"]["settings"], -): ReadonlyArray { - return legacyDefinedPostgresSettings(settings).flatMap(([key, value]) => [ - "-c", - `${key}=${String(value)}`, - ]); -} - /** * Port of Go's `config.VersionCompare` (`apps/cli-go/pkg/config/config.go:885-899`) * — NOT a real semver comparator. A dotted version with more than 3 components @@ -417,86 +379,6 @@ function legacyPostgresEntrypointScriptRestore(postgresConfig: string): string { ); } -/** - * Whether this container runs the `ghcr.io/supabase/cli` slim Postgres build rather than the - * docker.io one. The single gate shared by both spec builders and `start-database.ts`'s - * `--from-backup` refusal, so the guard can never drift from what the builders actually emit. - */ -export function legacyIsSlimPostgresImage(image: string): boolean { - return slimImagesEnabled() && isSlimImageRef(image); -} - -/** - * The bytes the slim image's `migrate.sh` runs as `supabase_admin` after bundled - * migrations: schema.sql + webhook.sql + _supabase.sql — the same postinit both - * image families run (the bundled demote migration leaves `postgres` - * NOSUPERUSER on both). - */ -const LEGACY_POSTGRES_SLIM_SCHEMA_SQL = `${LEGACY_START_DB_SCHEMA_SQL}\n${LEGACY_START_DB_WEBHOOK_SQL}\n${LEGACY_START_DB_SUPABASE_SQL}`; - -/** - * The image-dependent half of a Postgres container spec: how the entrypoint is - * driven, which env it reads, and which files have to be staged before it runs. - * Shared by the real `db` container and the shadow, whose slim wiring differs - * only by the shadow's extra worker-cap argv. - */ -type LegacyPostgresBootFields = Pick< - LegacyStartContainerSpec, - "env" | "entrypoint" | "cmd" | "secretFiles" ->; - -/** - * Slim-image wiring. The slim build has none of the seams the docker.io - * entrypoint script relies on (no `docker-entrypoint.sh`, no writable - * `/etc/postgresql/postgresql.conf`, and a non-root `65532` runtime user), so - * instead of overriding the entrypoint this keeps the image's own - * `sh /usr/local/bin/entry.sh` and drives it through the two seams it does - * expose: - * - * - **Trailing argv** — `entry.sh` ends in `exec postgres -D $PGDATA "$@"`, so - * `[db.settings]` travels as `-c key=value` pairs - * ({@link legacyPostgresSettingsToConfigArgs}) rather than a conf-file append. - * Everything the docker.io script appends by hand (`listen_addresses`, `port`, - * `wal_level`, the `host all all all scram-sha-256` HBA rule) `entry.sh` - * already applies itself on first boot. - * - **{@link LEGACY_POSTGRES_SCHEMA_SQL_PATH}** — staged as a `secretFiles` - * entry so the bundled `migrate.sh` finds it and runs it exactly once, at - * initdb, as `supabase_admin` under `ON_ERROR_STOP=1`. That is the same - * post-migration hook the docker.io image runs, which is why this needs no - * first-boot bookkeeping of its own: a reused data directory skips - * `migrate.sh` entirely, so the non-idempotent schema SQL cannot re-apply. - * - * `PGSODIUM_KEY_FILE` points pgsodium/vault's bundled `getkey_script` at the - * root key this port already delivers via `docker cp`; without it the script - * generates a fresh random key under `$HOME`, so the cluster's encryption key - * would differ from `db.root_key` on every provision. `POSTGRES_HOST` and - * `POSTGRES_INITDB_ARGS` are deliberately absent: both configure the docker.io - * entrypoint only, and the slim bundle owns its own initdb flags. - */ -function legacyPostgresSlimBootFields(input: { - readonly settings: CliConfig["db"]["settings"]; - readonly rootKey: string; - readonly password: string; - readonly jwtSecret: string; - readonly jwtExpiry: number; - /** Extra `postgres` argv appended after the `[db.settings]` pairs — the shadow's own worker cap. */ - readonly extraArgs: ReadonlyArray; -}): LegacyPostgresBootFields { - return { - env: { - POSTGRES_PASSWORD: input.password, - JWT_SECRET: input.jwtSecret, - JWT_EXP: String(input.jwtExpiry), - PGSODIUM_KEY_FILE: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, - }, - cmd: [...legacyPostgresSettingsToConfigArgs(input.settings), ...input.extraArgs], - secretFiles: [ - { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: input.rootKey }, - { containerPath: LEGACY_POSTGRES_SCHEMA_SQL_PATH, content: LEGACY_POSTGRES_SLIM_SCHEMA_SQL }, - ], - }; -} - /** * Builds the {@link LegacyStartContainerSpec} for the Postgres container — shared by `supabase * start` (always {@link LegacyPostgresStartServiceInput.fromBackup} `undefined`) and `db start`'s @@ -511,7 +393,6 @@ export function legacyBuildPostgresStartContainerSpec( const postgresConfig = legacyPostgresSettingsToPostgresConfig(input.db.settings); const isPg14OrEarlier = input.db.major_version <= 14; const isRestore = input.fromBackup !== undefined; - const isSlim = legacyIsSlimPostgresImage(input.image); const env: Record = { // The constant `"postgres"` literal, matching Go, where `Db.Password` is @@ -533,37 +414,24 @@ export function legacyBuildPostgresStartContainerSpec( ? legacyPostgresEntrypointScriptPg14(postgresConfig) : legacyPostgresEntrypointScriptPg15(postgresConfig); - const bootFields: LegacyPostgresBootFields = isSlim - ? legacyPostgresSlimBootFields({ - settings: input.db.settings, - rootKey: rootKeyValue, - password: LEGACY_POSTGRES_PASSWORD, - jwtSecret: input.jwtSecret, - jwtExpiry: input.jwtExpiry, - extraArgs: [], - }) - : { - env, - entrypoint: "sh", - cmd: ["-c", script], - // The pgsodium root key heredoc/bind is present whenever the ACTUAL entrypoint in use - // embeds it: both `legacyPostgresEntrypointScriptPg15` and - // `legacyPostgresEntrypointScriptRestore` do (Go's `fromBackup` override always re-adds - // its own root-key heredoc, `start.go:147,155`, regardless of major version); only the - // PG<=14 script never references it. - ...(isPg14OrEarlier && !isRestore - ? {} - : { - secretFiles: [ - { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, - ], - }), - }; - return { image: input.image, containerName, - ...bootFields, + 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 — @@ -578,7 +446,7 @@ export function legacyBuildPostgresStartContainerSpec( ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), ports: [{ hostPort: String(input.db.port), containerPort: "5432" }], healthcheck: { - test: isSlim ? LEGACY_POSTGRES_SLIM_HEALTHCHECK_TEST : LEGACY_POSTGRES_HEALTHCHECK_TEST, + test: LEGACY_POSTGRES_HEALTHCHECK_TEST, intervalSeconds: LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS, timeoutSeconds: LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS, retries: LEGACY_POSTGRES_HEALTHCHECK_RETRIES, @@ -593,10 +461,8 @@ 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. Declared as argv because that is the shape the slim image - * consumes (trailing container argv, see {@link legacyPostgresSlimBootFields}); - * {@link LEGACY_SHADOW_ENTRYPOINT_ARGS} joins it back for the docker.io - * entrypoint script's own `` splice point. + * shadow database. {@link LEGACY_SHADOW_ENTRYPOINT_ARGS} joins it for the + * docker.io entrypoint script's own `` splice point. */ const LEGACY_SHADOW_ENTRYPOINT_ARGV: ReadonlyArray = ["-c", "max_worker_processes=0"]; @@ -668,10 +534,6 @@ export interface LegacyShadowPostgresContainerSpecInput { * it delivered before `docker start` — via `docker cp` straight into the container * (`container-lifecycle.ts`), same as every other container's `secretFiles`, never a * host temp file. - * - **Under the slim image the whole entrypoint/env/`secretFiles` group comes from - * {@link legacyPostgresSlimBootFields} instead**, with the shadow's worker cap appended to - * the container's trailing argv rather than spliced into a script — everything below stays - * as described. * - **Labels ARE still applied** (merged in by `legacyCreateContainer`, same as every * other container) so `supabase stop`'s label-filtered sweep catches an orphaned shadow * too — Go's `DockerStart` sets `CliProjectLabel`/`composeProjectLabel` unconditionally, @@ -685,7 +547,6 @@ export function legacyBuildShadowPostgresContainerSpec( const rootKeyValue = input.rootKey ?? LEGACY_POSTGRES_DEFAULT_ROOT_KEY; const postgresConfig = legacyPostgresSettingsToPostgresConfig(input.db.settings); const isPg14OrEarlier = input.db.major_version <= 14; - const isSlim = legacyIsSlimPostgresImage(input.image); const env: Record = { POSTGRES_PASSWORD: input.password, @@ -699,38 +560,25 @@ export function legacyBuildShadowPostgresContainerSpec( ? legacyPostgresEntrypointScriptPg14(postgresConfig, LEGACY_SHADOW_ENTRYPOINT_ARGS) : legacyPostgresEntrypointScriptPg15(postgresConfig, LEGACY_SHADOW_ENTRYPOINT_ARGS); - const bootFields: LegacyPostgresBootFields = isSlim - ? legacyPostgresSlimBootFields({ - settings: input.db.settings, - rootKey: rootKeyValue, - password: input.password, - jwtSecret: input.jwtSecret, - jwtExpiry: input.jwtExpiry, - extraArgs: LEGACY_SHADOW_ENTRYPOINT_ARGV, - }) - : { - env, - entrypoint: "sh", - cmd: ["-c", script], - ...(isPg14OrEarlier - ? {} - : { - secretFiles: [ - { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, - ], - }), - }; - return { image: input.image, containerName: "", - ...bootFields, + env, + entrypoint: "sh", + cmd: ["-c", script], + ...(isPg14OrEarlier + ? {} + : { + secretFiles: [ + { 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: isSlim ? LEGACY_POSTGRES_SLIM_HEALTHCHECK_TEST : LEGACY_POSTGRES_HEALTHCHECK_TEST, + 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 5c18223ff6..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 @@ -11,7 +11,6 @@ import { legacyBuildPostgresStartContainerSpec, legacyBuildShadowPostgresContainerSpec, legacyPostgresImageVersionTag, - legacyPostgresSettingsToConfigArgs, legacyPostgresSettingsToPostgresConfig, legacyPostgresVersionCompare, type LegacyPostgresStartServiceInput, @@ -20,8 +19,6 @@ import { const POSTGRES_CONFIG_HEADER = "\n# supabase [db.settings] configuration\n"; -const SLIM_POSTGRES_IMAGE = "ghcr.io/supabase/cli/postgres:17.6.1.165"; - afterEach(() => { vi.unstubAllEnvs(); }); @@ -474,142 +471,3 @@ describe("legacyBuildShadowPostgresContainerSpec", () => { expect(spec.env?.["POSTGRES_PASSWORD"]).toBe("hunter2"); }); }); - -describe("legacyPostgresSettingsToConfigArgs", () => { - test("renders each set value as its own -c key=value pair, unquoted", () => { - expect( - legacyPostgresSettingsToConfigArgs({ - max_connections: 100, - shared_buffers: "128MB", - session_replication_role: "origin", - track_commit_timestamp: true, - }), - ).toEqual([ - "-c", - "max_connections=100", - "-c", - "shared_buffers=128MB", - "-c", - "session_replication_role=origin", - "-c", - "track_commit_timestamp=true", - ]); - }); - - test("emits nothing for empty settings, unlike the conf renderer's header-only output", () => { - expect(legacyPostgresSettingsToConfigArgs({})).toEqual([]); - expect(legacyPostgresSettingsToConfigArgs(undefined)).toEqual([]); - }); -}); - -describe("slim Postgres image spec", () => { - test("keeps the image's own entrypoint and passes [db.settings] as trailing -c argv instead of a heredoc script", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const spec = legacyBuildPostgresStartContainerSpec( - baseInput({ - image: SLIM_POSTGRES_IMAGE, - db: baseDb({ settings: { max_connections: 120, effective_cache_size: "512MB" } }), - }), - ); - - expect(spec.entrypoint).toBeUndefined(); - expect(spec.cmd).toEqual(["-c", "max_connections=120", "-c", "effective_cache_size=512MB"]); - expect(spec.cmd?.join(" ")).not.toContain("docker-entrypoint.sh"); - }); - - test("stages the schema SQL the bundled migrate.sh hook runs, plus the pgsodium root key it is pointed at by env", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const spec = legacyBuildPostgresStartContainerSpec( - baseInput({ image: SLIM_POSTGRES_IMAGE, rootKey: "custom-root-key" }), - ); - - expect(spec.env).toEqual({ - POSTGRES_PASSWORD: "postgres", - JWT_SECRET: "super-secret-jwt-token-with-at-least-32-characters-long", - JWT_EXP: "3600", - PGSODIUM_KEY_FILE: "/etc/postgresql-custom/pgsodium_root.key", - }); - expect(spec.secretFiles).toEqual([ - { containerPath: "/etc/postgresql-custom/pgsodium_root.key", content: "custom-root-key" }, - { - containerPath: "/etc/postgresql.schema.sql", - content: `${LEGACY_START_DB_SCHEMA_SQL}\n${LEGACY_START_DB_WEBHOOK_SQL}\n${LEGACY_START_DB_SUPABASE_SQL}`, - }, - ]); - }); - - test("leaves the volume bind, published port, healthcheck, and network wiring untouched", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const spec = legacyBuildPostgresStartContainerSpec( - baseInput({ image: SLIM_POSTGRES_IMAGE, db: baseDb({ port: 12345 }) }), - ); - - expect(spec.binds).toEqual(["supabase_db_myproj:/var/lib/postgresql/data"]); - expect(spec.ports).toEqual([{ hostPort: "12345", containerPort: "5432" }]); - expect(spec.healthcheck).toEqual({ - test: [ - "CMD", - "sh", - "-ec", - 'case "$(cat /proc/1/comm)" in postgres|.postgres-wrapp) pg_isready -U postgres -h 127.0.0.1 -p 5432 ;; *) exit 1 ;; esac', - ], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - }); - expect(spec.networkAliases).toEqual(["db", "db.supabase.internal"]); - expect(spec.restartPolicy).toBe("unless-stopped"); - }); - - test("healthcheck gates on the final postgres process, not the entrypoint shell PID 1 stays during first-boot init", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const dbSpec = legacyBuildPostgresStartContainerSpec(baseInput({ image: SLIM_POSTGRES_IMAGE })); - const shadowSpec = legacyBuildShadowPostgresContainerSpec( - baseShadowInput({ image: SLIM_POSTGRES_IMAGE }), - ); - const expected = [ - "CMD", - "sh", - "-ec", - 'case "$(cat /proc/1/comm)" in postgres|.postgres-wrapp) pg_isready -U postgres -h 127.0.0.1 -p 5432 ;; *) exit 1 ;; esac', - ]; - expect(dbSpec.healthcheck?.test).toEqual(expected); - expect(shadowSpec.healthcheck?.test).toEqual(expected); - }); - - test("appends the shadow's worker cap to the same trailing argv", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const spec = legacyBuildShadowPostgresContainerSpec( - baseShadowInput({ - image: SLIM_POSTGRES_IMAGE, - db: { major_version: 17, settings: { max_connections: 120 } }, - password: "hunter2", - }), - ); - - expect(spec.entrypoint).toBeUndefined(); - expect(spec.cmd).toEqual(["-c", "max_connections=120", "-c", "max_worker_processes=0"]); - expect(spec.env?.["POSTGRES_PASSWORD"]).toBe("hunter2"); - expect(spec.autoRemove).toBe(true); - expect(spec.secretFiles?.map((file) => file.containerPath)).toEqual([ - "/etc/postgresql-custom/pgsodium_root.key", - "/etc/postgresql.schema.sql", - ]); - }); - - // The whole slim path hangs off the resolved ref, so a registry override that - // lands on docker.io keeps the heredoc entrypoint even with the flag set. - test("stays on the docker.io entrypoint when the flag is set but the resolved image is not a slim ref", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const spec = legacyBuildPostgresStartContainerSpec(baseInput()); - expect(spec.entrypoint).toBe("sh"); - expect(spec.cmd?.[1]).toContain("exec docker-entrypoint.sh"); - }); - - test("stays on the docker.io entrypoint for a slim ref while the flag is off", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); - const spec = legacyBuildPostgresStartContainerSpec(baseInput({ image: SLIM_POSTGRES_IMAGE })); - expect(spec.entrypoint).toBe("sh"); - expect(spec.cmd?.[1]).toContain("exec docker-entrypoint.sh"); - }); -}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts index fdef2843d1..953617173e 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts @@ -5,12 +5,13 @@ * * Docker CLI `--health-cmd` is always stored as `CMD-SHELL` and executed with * `/bin/sh -c` (`docker-create-args.ts`). Distroless images with no `/bin/sh` - * (auth, storage, studio, pg-meta, edge-runtime) therefore cannot carry a - * Docker healthcheck through this CLI — omit it and let - * `legacyCheckContainerReady` treat `Running` as ready, the same as PostgREST. - * Elixir/busybox images (realtime, analytics) and Vector do ship `/bin/sh` plus - * a wget applet, so they keep an exec-form probe that the CLI quotes into - * CMD-SHELL. + * (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"; 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 6087331c84..7ed9a183dd 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -8,8 +8,8 @@ * Go's `StartDatabase` now only has one TS home to update. * * Exact Go call order: pre-create volume-existence probe (+ the `fromBackup`-on-an-existing-volume - * guard) -> image resolve (+ a TS-only slim-image reused-volume access guard, see below) + - * network ensure (Go's `DockerStart` resolves the image, THEN creates the network, both strictly + * 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 @@ -17,12 +17,6 @@ * `fromBackup` is set) -> `initCurrentBranch`, unconditionally (the LAST line of `StartDatabase`, * reached on every path that doesn't already return/fail above). * - * The slim-image volume-access guard has no Go equivalent (`SUPABASE_USE_SLIM_IMAGES` is a TS-only - * feature): on an existing volume, once the image is resolved, a cheap `docker run` probe checks - * whether a slim (non-root `65532`) image can actually read and write PGDATA before any container is - * created — a docker.io-initialized volume's `700`-mode dirs otherwise crash-loop the slim - * process until the health check times out with no useful message. - * * Deliberately has ZERO knowledge of `--ignore-health-check` — matching Go exactly: that flag is * `internal/start/start.go`'s `Run()`'s own concern, entirely OUTSIDE `StartDatabase` (Go's * `StartDatabase` has no `ignoreHealthCheck` parameter at all). `supabase start`'s own caller @@ -73,7 +67,6 @@ import type { LegacyDockerRun } from "../legacy-docker-run.service.ts"; import { legacyEnsureNetwork, legacyCreateContainer, - legacyIsVolumeAccessibleToImage, legacyVolumeExists, LEGACY_COMPOSE_PROJECT_LABEL, type LegacyContainerCreateError, @@ -103,7 +96,6 @@ import { } from "./messages.ts"; import { legacyBuildPostgresStartContainerSpec, - legacyIsSlimPostgresImage, type LegacyPostgresStartServiceInput, } from "./postgres.service.ts"; @@ -130,59 +122,11 @@ export class LegacyStartBackupVolumeExistsError extends Data.TaggedError( } } -/** - * `--from-backup` reached a slim Postgres image. The docker.io restore path is entirely a - * property of that image's entrypoint — `docker-entrypoint.sh` running the restore script - * this port heredocs into `/docker-entrypoint-initdb.d/migrate.sh` against the - * `/etc/backup.sql` bind (`postgres.service.ts`'s restore entrypoint variant) — and the slim - * build ships neither seam, so a restore would silently start an empty cluster instead. - * Refused before any container is created. Exported only so the exhaustive actionability - * guard can inspect its declaration. - */ -export class LegacySlimImagesBackupUnsupportedError extends Data.TaggedError( - "LegacySlimImagesBackupUnsupportedError", -)<{ - readonly message: string; - readonly suggestion?: string; -}> { - // The remediation is to change what the caller passed in — the env flag, not the config file - // — and the error carries the concrete instruction, so this matches `provideFlags` rather - // than the suggestion-free `invalidInput`. - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - -/** - * An existing db volume was initialized by a docker.io Postgres image (PGDATA owned by that - * image's `postgres` uid, `700`-mode dirs) and is being reused under the slim image, whose - * `65532` runtime user cannot read it — the slim process would otherwise crash-loop until the - * health check times out with no useful message. Detected by a pre-create access probe (read + write — - * Postgres must write under PGDATA) - * (`legacyIsVolumeAccessibleToImage`), reached only when the resolved image is slim AND the volume - * already existed. Exported only so the exhaustive actionability guard can inspect its - * declaration. - */ -export class LegacySlimImageVolumeInaccessibleError extends Data.TaggedError( - "LegacySlimImageVolumeInaccessibleError", -)<{ - readonly message: string; - readonly suggestion?: string; -}> { - // Same shape as `LegacySlimImagesBackupUnsupportedError`: the fix is to change what the - // caller passed in — reset the volume or unset the env flag — not a suggestion-free default. - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** Every failure {@link legacyStartDatabase} itself can produce, independent of the caller's own `E`. */ export type LegacyStartDatabaseError = | LegacyNetworkCreateError | LegacyVolumeInspectError | LegacyStartBackupVolumeExistsError - | LegacySlimImagesBackupUnsupportedError - | LegacySlimImageVolumeInaccessibleError | LegacyVolumeCreateError | LegacyContainerCreateError | LegacyContainerStartError @@ -281,42 +225,8 @@ export const legacyStartDatabase = ( const resolvedPostgresImage = yield* input.resolvePostgresImage; - // Slim restore has no entrypoint. Refuse before publishing freshness so - // rollback cannot prune leftover sibling volumes from a prior image family. - // Gated on the resolved ref, not the env flag: a registry override can - // still land this run on a docker.io image, which restores fine. - if (fromBackup !== undefined && legacyIsSlimPostgresImage(resolvedPostgresImage)) { - return yield* Effect.fail( - new LegacySlimImagesBackupUnsupportedError({ - message: "--from-backup is not supported with SUPABASE_USE_SLIM_IMAGES", - suggestion: "Unset SUPABASE_USE_SLIM_IMAGES to restore from a backup.", - }), - ); - } - input.onFreshVolumeResolved(isFreshVolume); - // A reused volume's PGDATA ownership is a property of whichever image initialized it, not - // of the image resolved for THIS run — a docker.io-initialized volume's `700`-mode dirs - // block the slim image's non-root user. Only reachable on an existing volume; a fresh one - // has no pre-existing ownership to conflict with. - if (!isFreshVolume && legacyIsSlimPostgresImage(resolvedPostgresImage)) { - const accessible = yield* legacyIsVolumeAccessibleToImage( - spawner, - resolvedPostgresImage, - input.dbContainerId, - ); - if (!accessible) { - return yield* Effect.fail( - new LegacySlimImageVolumeInaccessibleError({ - message: - "the existing database volume was initialized by a non-slim postgres image and is not readable and writable by the slim image's user", - suggestion: `Run ${legacyAqua("supabase stop --no-backup")} to reset the local database, or unset SUPABASE_USE_SLIM_IMAGES.`, - }), - ); - } - } - // Go's `DockerStart` (`docker.go:363-386`): image resolve, THEN network create, both // strictly ahead of container create — hoisted here to run ONCE per `start` run instead of // once per container (Go's own repeated per-container call is a no-op after the first, see diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts index 043487ac44..3841ed4d4c 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts @@ -26,18 +26,11 @@ export const legacyEdgeRuntimeImage = () => dockerfileServiceImage("edgeruntime" // 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(); -} - const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, workdir: string, denoVersion: number, - slim: boolean, ) { if (denoVersion === 1) { return LEGACY_EDGE_RUNTIME_DENO1_IMAGE; @@ -51,9 +44,6 @@ const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( if (pinned === DENO1_EDGE_RUNTIME_VERSION) { return LEGACY_EDGE_RUNTIME_DENO1_IMAGE; } - if (!slim) { - return pinned.length > 0 ? replaceImageTag(raw, pinned) : raw; - } return slimImageForCurrentPin("edgeruntime", raw, pinned.length > 0 ? pinned : undefined); }); @@ -69,17 +59,10 @@ export const legacyResolveEdgeRuntimeImage = ( path: Path.Path, workdir: string, denoVersion: number, -) => resolveEdgeRuntimeImage(fs, path, workdir, denoVersion, true); +) => resolveEdgeRuntimeImage(fs, path, workdir, denoVersion); /** - * Same resolution pinned to docker.io, for callers that replace the image - * entrypoint with a shell. The slim edge-runtime image is distroless: its only - * executables are `/usr/bin/edge-runtime` and its wrapper, so `sh -c …` cannot - * run there at all — the same locked exception the `deno1` tag already carries. + * 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 = ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - denoVersion: number, -) => resolveEdgeRuntimeImage(fs, path, workdir, denoVersion, false); +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 f34d73f902..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 @@ -122,7 +122,7 @@ describe("legacyResolveEdgeRuntimeImage", () => { ); }); - it.effect("keeps the shell-pinned resolution on docker.io, pin included", () => { + 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 }); @@ -137,6 +137,24 @@ describe("legacyResolveEdgeRuntimeImage", () => { ); }); + 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-")); 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 282a1824b0..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 @@ -193,9 +193,7 @@ describe("legacyEdgeRuntimeScriptLayer sentinel handling", () => { }, ); - it.effect("keeps the runner on the docker.io image with the slim-images flag on", () => { - // The runner replaces the entrypoint with `sh -c `; the distroless - // slim image ships no shell, so it must never be selected here. + 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, @@ -206,7 +204,7 @@ describe("legacyEdgeRuntimeScriptLayer sentinel handling", () => { Effect.tap(() => Effect.sync(() => { expect(docker.lastOpts?.entrypoint).toStrictEqual(Option.some("sh")); - expect(docker.lastOpts?.image).not.toContain("ghcr.io/supabase/cli/"); + expect(docker.lastOpts?.image).toContain("ghcr.io/supabase/cli/"); expect(docker.lastOpts?.image).toContain("edge-runtime:"); }), ), diff --git a/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts index bfa8ea8785..3194df9546 100644 --- a/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts +++ b/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts @@ -3,7 +3,6 @@ import { Effect, Option } from "effect"; import { LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; -import { legacyIsSlimPostgresImage } from "./db-bootstrap/postgres.service.ts"; import { legacyGetRegistryImageUrl } from "./legacy-docker-registry.ts"; import { LegacyDockerRun } from "./legacy-docker-run.service.ts"; @@ -65,18 +64,10 @@ export const legacyStreamPgDump = Effect.fnUntraced(function* (params: { : { _tag: "host" as const }; const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; - // The docker.io entrypoint execs non-`postgres` argv directly, so a plain `["bash", "-c", - // script, "--"]` cmd runs under the image's own entrypoint. The slim image's `entry.sh` - // instead always initdb's and execs `postgres` with whatever argv it is given, so this - // one-shot job needs its entrypoint overridden to the shell the cmd expects. - const isSlim = legacyIsSlimPostgresImage(params.image); - return yield* docker.runStream( { image: legacyGetRegistryImageUrl(params.image), - ...(isSlim - ? { entrypoint: Option.some("bash"), cmd: ["-c", params.script, "--"] } - : { cmd: ["bash", "-c", params.script, "--"] }), + cmd: ["bash", "-c", params.script, "--"], env: params.env, binds: [], workingDir: Option.none(), diff --git a/apps/cli/src/legacy/shared/legacy-pg-dump.run.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.run.unit.test.ts index 2e7882e63e..4cfd6f2e04 100644 --- 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 @@ -74,10 +74,10 @@ describe("legacyStreamPgDump entrypoint wiring", () => { expect(opts.cmd).toEqual(["bash", "-c", "pg_dump", "--"]); }); - test("slim image + flag on: overrides the entrypoint to bash, since entry.sh would otherwise initdb and exec the dump script as postgres argv", () => { + 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(Option.getOrUndefined(opts.entrypoint ?? Option.none())).toBe("bash"); - expect(opts.cmd).toEqual(["-c", "pg_dump", "--"]); + expect(opts.entrypoint).toBeUndefined(); + expect(opts.cmd).toEqual(["bash", "-c", "pg_dump", "--"]); }); }); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 4022bfac8c..63252b3c29 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -27,7 +27,6 @@ import { hasExplicitLongFlag, lastExplicitLongFlagValue, } from "../cli/cobra-flag-groups.ts"; -import { usesSlimImageRuntime } from "../services/slim-images.ts"; import { edgeRuntimeImage, FUNCTIONS_BUNDLER_MUTEX_GROUP, @@ -1201,12 +1200,6 @@ export async function buildDockerBinds( readonly additionalModuleRoots?: ReadonlyArray; readonly onWarning?: (message: string) => Promise; readonly skipMissingImportMapTargets?: boolean; - /** - * Resolved edge-runtime ref — a slim (uid 65532) ref selects the - * slim-only Deno-cache volume (`edgeRuntimeCacheVolume`) instead of the - * root-owned docker.io one. - */ - readonly image?: string; } = {}, ): Promise> { const hostFunctionsDir = resolve(functionsDir); @@ -1238,10 +1231,7 @@ export async function buildDockerBinds( }, ]; if (process.env["BITBUCKET_CLONE_DIR"] === undefined) { - const cacheVolume = edgeRuntimeCacheVolume( - projectId, - options.image !== undefined && usesSlimImageRuntime(options.image), - ); + const cacheVolume = edgeRuntimeCacheVolume(projectId); binds.unshift({ hostPath: cacheVolume.name, containerPath: cacheVolume.containerPath, @@ -1436,16 +1426,11 @@ 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`). The - // slim gate reads the raw reference: registry-candidate mapping never - // moves an image into or out of the slim `ghcr.io/supabase/cli/` - // namespace, so raw and pull-resolved refs agree on slim-ness. + // prefix or not (see the helper's doc in `functions.shared.ts`). const rawImage = edgeRuntimeImage(edgeRuntimeVersion); - const slim = usesSlimImageRuntime(rawImage); const binds = yield* Effect.promise(() => buildDockerBinds(projectId, functionsDir, outputDir, config, { onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), - image: rawImage, }), ); // Go: `DockerStart` -> `DockerResolveImageIfNotCached` (`internal/utils/docker.go:326-386`) @@ -1457,7 +1442,7 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( // so the only cost is one cached `docker image inspect` per function. const image = yield* resolveFunctionsDockerImage(rawImage, projectEnvValues); yield* ensureDockerNetwork(networkMode, projectId); - yield* ensureDockerNamedVolume(edgeRuntimeCacheVolume(projectId, slim).name, 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 06a21da233..3f596b108d 100644 --- a/apps/cli/src/shared/functions/deploy.unit.test.ts +++ b/apps/cli/src/shared/functions/deploy.unit.test.ts @@ -667,11 +667,7 @@ describe("buildDockerBinds — edge-runtime Deno-cache volume selection", () => } }); - it("mounts the slim-only volume over /home/nonroot when the image is slim", async () => { - // The slim (uid 65532) edge-runtime resolves Deno's cache under its own - // $HOME, and a docker.io-seeded volume is root-owned — so slim runs get a - // separate volume mounted over the nonroot home (see - // `edgeRuntimeCacheVolume`). + 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( {}, @@ -679,12 +675,10 @@ describe("buildDockerBinds — edge-runtime Deno-cache volume selection", () => ); try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { - image: "ghcr.io/supabase/cli/edge-runtime:v1.74.3", - }); + const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config); const formatted = binds.map(formatDockerBind); - expect(formatted).toContain("supabase_edge_runtime_slim_test-project:/home/nonroot:rw"); - expect(formatted).not.toContain("supabase_edge_runtime_test-project:/root/.cache/deno:rw"); + 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 64de82eb64..2b85ba0611 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -1,6 +1,6 @@ import { operationDefinitions, SupabaseApiInputError, type ApiClient } from "@supabase/api/effect"; import { randomUUID } from "node:crypto"; -import { mkdir, open, realpath, rename, rm, writeFile } from "node:fs/promises"; +import { mkdir, open, rename, rm, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { Effect, FileSystem, Option } from "effect"; @@ -17,9 +17,7 @@ import { import { legacyDescribeContainerCliFailure } from "../../legacy/shared/legacy-container-cli.ts"; import { legacyViperEnvStringWithProjectFallback } from "../legacy/legacy-viper-env.ts"; import { - buildFunctionsDockerCreateArgs, buildFunctionsDockerRunArgs, - containerArchiveFiles, edgeRuntimeCacheVolume, ensureDockerNamedVolume, ensureDockerNetwork, @@ -28,9 +26,7 @@ import { resolveEdgeRuntimeVersion, resolveFunctionsDockerImage, runChildProcess, - runChildProcessBinaryStdout, } from "./functions-docker.ts"; -import { usesSlimImageRuntime } from "../services/slim-images.ts"; import { loadFunctionsCliConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; import { edgeRuntimeImage, @@ -53,17 +49,6 @@ const legacyEntrypointPath = "file:///src/index.ts"; // deploy's `toDockerPath` host-mirroring scheme. const DOCKER_DENO_DIR = "/home/deno"; const dockerIoEszipDir = "/root/eszips"; -// Slim (uid 65532) counterparts: `/tmp` is the one image directory writable -// and traversable by the non-root user on every published slim tag (older -// slim images ship `/root` as 0700), so the eszip bind and the unbundle -// output both live under it. The output deliberately is NOT a bind: `docker -// cp` reads it back after the container exits (binds and volumes are -// invisible to `cp` on a stopped container), which is what keeps uid 65532 -// from ever writing into the host `supabase/functions` directory — on native -// Linux that bind write EACCESes whenever the host directory belongs to a -// different uid. -const slimEszipDir = "/tmp/eszips"; -const slimUnbundleDir = "/tmp/unbundle"; export interface DownloadFunctionsOptions { readonly functionName: Option.Option; @@ -1080,19 +1065,9 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( const { projectId, denoVersion, image, projectEnvValues } = edgeRuntimeImage; const functionsDir = resolve(dependencies.projectRoot, "supabase", "functions"); const hostEszipPath = resolve(eszipPath); - // The slim edge-runtime image runs as uid 65532, which cannot write into - // the host `supabase/functions` bind the docker.io (root) flow extracts - // straight into — on native Linux that write EACCESes whenever the host - // directory belongs to a different uid. The slim flow therefore keeps the - // container's filesystem as the only write target and copies the extracted - // files out with `docker cp` afterwards, writing them host-side as the - // invoking user. See `slimEszipDir`'s doc for the path choices. - const slim = usesSlimImageRuntime(image); - const cacheVolume = edgeRuntimeCacheVolume(projectId, slim); - const dockerEszipPath = posix.join(slim ? slimEszipDir : dockerIoEszipDir, eszipFileName); - const dockerOutputPath = slim - ? posix.join(slimUnbundleDir, slug) - : posix.join(DOCKER_DENO_DIR, slug); + 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`) — // `--network-id` is a persistent root flag (`cmd/root.go:328`), not @@ -1127,12 +1102,11 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( // explicit creation — `docker run -v :...` would otherwise still // implicitly create the named volume, which Bitbucket's restricted Docker // environment doesn't allow, same carve-out as `deploy.ts`'s - // `buildDockerBinds`. The slim flow drops the `supabase/functions` bind: - // its output stays container-local and is copied out below instead. + // `buildDockerBinds`. const binds = [ ...(process.env["BITBUCKET_CLONE_DIR"] === undefined ? [cacheVolume.bind] : []), `${hostEszipPath}:${dockerEszipPath}:ro`, - ...(slim ? [] : [`${functionsDir}:${DOCKER_DENO_DIR}:rw`]), + `${functionsDir}:${DOCKER_DENO_DIR}:rw`, ]; const spec = { image, @@ -1193,157 +1167,17 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( } }); - if (!slim) { - 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 - // the `unbundle` container itself wrote. - return slug; - } - - // Slim: `create` → `start --attach` → `cp` out → `rm -f`, instead of a - // single `docker run --rm` — the extracted files live on the container's - // own filesystem, so the container must still exist after it exits for - // `docker cp` to read them. - const containerName = `supabase_unbundle_${slug}_${randomUUID().slice(0, 8)}`; - yield* Effect.gen(function* () { - const created = yield* runChildProcess( - "docker", - buildFunctionsDockerCreateArgs(containerName, spec), - { stdout: "ignore", stderr: "pipe" }, - ).pipe( - Effect.mapError( - withDockerStepFailure( - "failed to create the edge-runtime unbundle container", - slug, - styleAqua, - ), - ), - ); - if (created.exitCode !== 0) { - return yield* Effect.fail( - Object.assign( - new Error( - `failed to create the edge-runtime unbundle container: ${ - created.stderr.trim().length > 0 - ? created.stderr.trim() - : `exit ${created.exitCode}` - }`, - ), - { suggestion: suggestLegacyBundle(slug, styleAqua) }, - ), - ); - } - - yield* runUnbundleContainer(["start", "--attach", containerName]); - - const copied = yield* runChildProcessBinaryStdout("docker", [ - "cp", - `${containerName}:${dockerOutputPath}`, - "-", - ]).pipe( - Effect.mapError( - withDockerStepFailure( - "failed to copy the extracted Function files out of the unbundle container", - slug, - styleAqua, - ), - ), - ); - if (copied.exitCode !== 0) { - return yield* Effect.fail( - Object.assign( - new Error( - `failed to copy the extracted Function files out of the unbundle container: ${ - copied.stderr.trim().length > 0 ? copied.stderr.trim() : `exit ${copied.exitCode}` - }`, - ), - { suggestion: suggestLegacyBundle(slug, styleAqua) }, - ), - ); - } - - yield* writeUnbundledArchive(copied.stdout, functionsDir, slug); - }).pipe( - // Same cleanup guarantee `--rm` gives the docker.io flow: the - // container is removed on every path out of the lifecycle above, - // including a failed start or copy. Removal failures are swallowed — - // an orphaned stopped container must not mask the real error. - Effect.ensuring( - runChildProcess("docker", ["rm", "--force", containerName], { - stdout: "ignore", - stderr: "ignore", - }).pipe(Effect.ignore), - ), - ); - + 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 + // the `unbundle` container itself wrote. return slug; }); return yield* extract.pipe(Effect.ensuring(cleanupEszip)); }); -/** - * Writes the `docker cp`-exported unbundle output (a tar rooted at the slug - * directory) into `/`, merging over existing files the - * same way the docker.io flow's direct bind write does — but host-side, as - * the invoking user, which is the whole point of the slim copy-out flow. - * Entry paths originate from a container run over a remote eszip, so each - * destination is containment-checked against `functionsRoot` (resolved and - * realpath'd like `downloadSingle`'s multipart writes) before anything is - * written. - */ -const writeUnbundledArchive = Effect.fnUntraced(function* ( - archive: Uint8Array, - functionsRoot: string, - slug: string, -) { - const files = yield* Effect.tryPromise({ - try: () => containerArchiveFiles(archive), - catch: (cause) => - new Error( - `failed to read the extracted Function files: ${cause instanceof Error ? cause.message : String(cause)}`, - ), - }); - - const functionDir = join(functionsRoot, slug); - const mapMkdirError = (cause: unknown) => - new Error(`failed to mkdir: ${cause instanceof Error ? cause.message : String(cause)}`); - yield* Effect.tryPromise({ - try: () => mkdir(functionDir, { recursive: true }), - catch: mapMkdirError, - }); - const realFunctionsRoot = yield* Effect.tryPromise({ - try: () => realpath(functionsRoot), - catch: mapMkdirError, - }); - - for (const [name, body] of files) { - // `docker cp : -` roots every entry at the copied - // directory's basename — the slug. - const segments = name.replace(/^\.\//, "").split("/"); - const parts = segments[0] === slug ? segments.slice(1) : segments; - if (parts.every((part) => part.length === 0)) { - continue; - } - - const destination = resolve(functionDir, ...parts); - yield* ensureContainedPath(resolve(functionsRoot), destination, name); - const parent = dirname(destination); - yield* Effect.tryPromise({ - try: () => mkdir(parent, { recursive: true }), - catch: mapMkdirError, - }); - // A pre-existing symlinked subdirectory must not let a crafted entry - // path escape the functions root once resolved on disk. - yield* Effect.tryPromise({ try: () => realpath(parent), catch: mapMkdirError }).pipe( - Effect.flatMap((realParent) => ensureContainedPath(realFunctionsRoot, realParent, name)), - ); - yield* writeFileWithoutFollowingSymlinks(destination, body, name); - } -}); - const downloadSingle = Effect.fnUntraced(function* ( dependencies: DownloadRuntimeDependencies, projectRef: string, diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 124ec0f420..f8d037a66a 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -30,34 +30,13 @@ export function localDockerId(name: string, projectId: string) { } /** - * The slim edge-runtime image's home directory (distroless `nonroot`, uid - * 65532) — same layout contract the slim Storage volume mount relies on - * (`legacy/commands/start/services/storage.service.ts`). + * 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`. */ -const SLIM_EDGE_RUNTIME_HOME = "/home/nonroot"; - -/** - * The Deno-cache volume bind for an edge-runtime container. - * - * docker.io edge-runtime runs as root, so the shared - * `supabase_edge_runtime_` volume mounts at `/root/.cache/deno` - * (Deno's cache under root's `$HOME`) and its contents end up root-owned. - * - * The slim image runs as uid 65532 with `$HOME=/home/nonroot`, which breaks - * that contract twice over: Deno resolves its cache under `/home/nonroot`, - * not `/root`, and a volume already seeded root-owned by a docker.io run can - * never be made writable by 65532 by remounting it. So slim runs use their - * OWN volume (`supabase_edge_runtime_slim_`) mounted over the - * home directory: Docker seeds a fresh volume from the image's - * `/home/nonroot` (owned by 65532), so `$HOME`-relative caches (deno, npm) - * land inside it writable — the same mount-the-owned-home-dir pattern the - * slim Storage container uses. The two volume families never share a name, - * so no cross-family ownership probe is needed; both carry the - * `com.supabase.cli.project` label `supabase stop --no-backup` prunes by. - */ -export function edgeRuntimeCacheVolume(projectId: string, slim: boolean) { - const name = localDockerId(slim ? "edge_runtime_slim" : "edge_runtime", projectId); - const containerPath = slim ? SLIM_EDGE_RUNTIME_HOME : "/root/.cache/deno"; +export function edgeRuntimeCacheVolume(projectId: string) { + const name = localDockerId("edge_runtime", projectId); + const containerPath = "/root/.cache/deno"; return { name, containerPath, @@ -135,26 +114,6 @@ export function containerArchiveBytes( ).bytes(); } -/** - * Inverse of {@link containerArchiveBytes}: decodes a `docker cp - * : -` tar stream into entry-name → file-bytes. Directory - * entries (trailing `/`) are dropped — callers recreate parents from the - * file paths themselves. - */ -export async function containerArchiveFiles( - archive: Uint8Array, -): Promise> { - const entries = await new Bun.Archive(archive).files(); - const files = new Map(); - for (const [name, blob] of entries) { - if (name.endsWith("/")) { - continue; - } - files.set(name, new Uint8Array(await blob.arrayBuffer())); - } - return files; -} - export interface FunctionsDockerRunSpec { /** Already registry/pull-resolved image reference. */ readonly image: string; @@ -190,20 +149,6 @@ export function buildFunctionsDockerRunArgs(spec: FunctionsDockerRunSpec): Array return buildFunctionsDockerContainerArgs(["run", "--rm"], spec); } -/** - * `docker create --name ` variant of - * {@link buildFunctionsDockerRunArgs} — for one-shot containers whose - * filesystem must be reachable by `docker cp` after they exit (a `--rm` run - * removes it before anything can be copied out). The caller owns the - * `start -a` / `cp` / `rm -f` lifecycle around it. - */ -export function buildFunctionsDockerCreateArgs( - containerName: string, - spec: FunctionsDockerRunSpec, -): Array { - return buildFunctionsDockerContainerArgs(["create", "--name", containerName], spec); -} - function buildFunctionsDockerContainerArgs( head: ReadonlyArray, spec: FunctionsDockerRunSpec, @@ -309,52 +254,6 @@ export const runChildProcess = Effect.fnUntraced(function* ( ); }); -/** - * {@link runChildProcess} variant whose stdout is collected as raw bytes - * instead of decoded text — for `docker cp : -`, whose - * stdout is a tar archive that UTF-8 decoding would corrupt. stderr stays - * text (it only ever carries diagnostics). - */ -export const runChildProcessBinaryStdout = Effect.fnUntraced(function* ( - command: string, - args: ReadonlyArray, -) { - return yield* Effect.scoped( - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawnContainerCli(spawner, [...args], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - extendEnv: command === "docker", - }); - - const stdoutChunks: Array = []; - const [, stderr, exitCode] = yield* Effect.all( - [ - Stream.runForEach(child.stdout, (chunk) => - Effect.sync(() => { - stdoutChunks.push(chunk); - }), - ), - collectByteStream(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - - const total = stdoutChunks.reduce((size, chunk) => size + chunk.length, 0); - const stdout = new Uint8Array(total); - let offset = 0; - for (const chunk of stdoutChunks) { - stdout.set(chunk, offset); - offset += chunk.length; - } - return { exitCode, stdout, stderr }; - }), - ); -}); - // Go: `container.NetworkMode.IsContainer()` (`docker/api/types/container/hostconfig.go:152-155`, // via the unexported `containerID` helper, same file:493-499) — `--network container:` // (Docker's syntax for attaching to another container's network stack) is recognized by a bare 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 4a4282c40b..76ddb2c83d 100644 --- a/apps/cli/src/shared/functions/functions-docker.unit.test.ts +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -5,15 +5,12 @@ import { Deferred, Effect, Layer, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - buildFunctionsDockerCreateArgs, buildFunctionsDockerRunArgs, containerArchiveBytes, - containerArchiveFiles, edgeRuntimeCacheVolume, localDockerId, resolveDockerNetworkMode, runChildProcess, - runChildProcessBinaryStdout, toDockerPath, } from "./functions-docker.ts"; @@ -216,44 +213,13 @@ describe("buildFunctionsDockerRunArgs", () => { }); describe("edgeRuntimeCacheVolume", () => { - it("keeps the shared volume at /root/.cache/deno for docker.io images", () => { - expect(edgeRuntimeCacheVolume("my-project", false)).toEqual({ + 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", }); }); - - it("uses a slim-only volume mounted over the nonroot home for slim images", () => { - // A docker.io-seeded volume is root-owned and can never be made writable - // by uid 65532 by remounting it, so the two image families must never - // share a volume name — see the helper's doc comment. - expect(edgeRuntimeCacheVolume("my-project", true)).toEqual({ - name: "supabase_edge_runtime_slim_my-project", - containerPath: "/home/nonroot", - bind: "supabase_edge_runtime_slim_my-project:/home/nonroot:rw", - }); - }); -}); - -describe("buildFunctionsDockerCreateArgs", () => { - it("emits create --name with the same tail as the run variant", () => { - const spec = { - image: "ghcr.io/supabase/cli/edge-runtime:v1.2.3", - projectId: "my-project", - networkMode: "supabase_network_my-project", - binds: ["/host/a:/container/a:ro"], - containerArgs: ["unbundle", "--eszip", "/tmp/eszips/x.eszip"], - platform: "darwin" as const, - }; - - expect(buildFunctionsDockerCreateArgs("supabase_unbundle_x_1234", spec)).toEqual([ - "create", - "--name", - "supabase_unbundle_x_1234", - ...buildFunctionsDockerRunArgs(spec).slice(2), - ]); - }); }); describe("containerArchiveBytes", () => { @@ -299,21 +265,6 @@ describe("containerArchiveBytes", () => { }); }); -describe("containerArchiveFiles", () => { - it("round-trips file entries, preserving binary bodies", async () => { - const binary = new Uint8Array([0x00, 0x80, 0xff, 0x10]); - const archive = await containerArchiveBytes({ - "myfn/index.ts": "console.log(1)\n", - "myfn/nested/util.bin": binary, - }); - - const files = await containerArchiveFiles(archive); - expect([...files.keys()].sort()).toEqual(["myfn/index.ts", "myfn/nested/util.bin"]); - expect(new TextDecoder().decode(files.get("myfn/index.ts"))).toBe("console.log(1)\n"); - expect(files.get("myfn/nested/util.bin")).toEqual(binary); - }); -}); - describe("resolveDockerNetworkMode", () => { it("prefers the explicit flag over the env override when both are set", () => { expect( @@ -427,24 +378,3 @@ describe("runChildProcess", () => { }), ); }); - -describe("runChildProcessBinaryStdout", () => { - it.effect("returns stdout as raw bytes, immune to UTF-8 decoding corruption", () => - Effect.gen(function* () { - // 0x80/0xFF are invalid UTF-8 lead bytes — text decoding would replace - // them (U+FFFD), corrupting a tar stream. The binary collector must - // return them verbatim, across chunk boundaries. - const chunk1 = new Uint8Array([0x00, 0x80]); - const chunk2 = new Uint8Array([0xff, 0x42]); - - const result = yield* runChildProcessBinaryStdout("docker", [ - "cp", - "container:/tmp/unbundle/x", - "-", - ]).pipe(Effect.provide(mockStreamingChildProcessLayer({ stdout: [chunk1, chunk2] }))); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toEqual(new Uint8Array([0x00, 0x80, 0xff, 0x42])); - }), - ); -}); diff --git a/apps/cli/src/shared/functions/serve.errors.ts b/apps/cli/src/shared/functions/serve.errors.ts deleted file mode 100644 index 9f16bbed29..0000000000 --- a/apps/cli/src/shared/functions/serve.errors.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Data } from "effect"; -import { - actionability, - type CliErrorActionabilityDeclaration, - ErrorActionabilityId, -} from "../telemetry/error-actionability.ts"; - -/** - * A newline-containing function secret while `SUPABASE_USE_SLIM_IMAGES` is on. - * Multiline values reach the container through a sourced shell script, and the - * slim edge-runtime image is distroless — it ships no shell to source it. - */ -export class SlimEdgeRuntimeMultilineSecretError extends Data.TaggedError( - "SlimEdgeRuntimeMultilineSecretError", -)<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // The remediation is editing the env file or the flag, not re-running with - // different arguments, so `invalidConfig` (which declares its - // update-config suggestion) fits better than `invalidInput`. - return actionability.invalidConfig; - } -} diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 2e7f5ce94e..07e349dac9 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -76,9 +76,7 @@ import { toDockerPath, } from "./functions-docker.ts"; import { loadFunctionsCliConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; -import { SlimEdgeRuntimeMultilineSecretError } from "./serve.errors.ts"; import { edgeRuntimeImage, resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; -import { usesSlimImageRuntime } from "../services/slim-images.ts"; const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); const defaultCliConfig = decodeCliConfig({}); @@ -111,8 +109,7 @@ const ignoredDirNames = new Set([ const dockerLogRetryDelay = Duration.millis(400); const dockerLogDiagnosticTailLength = 4_096; const defaultSupabaseEnv = "development"; -const slimServeMainDir = "/tmp"; -const dockerIoServeMainDir = "/root"; +const serveMainDir = "/root"; const shellVariableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/; let cachedLegacyFunctionsServeMainTemplate: string | undefined; const watchIgnoreGlobs = [ @@ -1656,8 +1653,6 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo const watchableBinds = new Map(); const emittedScopeWarnings = new Set(); const functionsConfig: Record = {}; - const slimEdgeRuntime = usesSlimImageRuntime(input.image); - for (const config of functionConfigs) { if (!config.enabled) { yield* output.raw(`Skipped serving Function: ${config.slug}\n`, "stderr"); @@ -1669,7 +1664,6 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo buildDockerBinds(projectId, functionsDir, functionsDir, config, { additionalModuleRoots: [input.flagCwd], skipMissingImportMapTargets: true, - image: input.image, onWarning: async (message) => { bindWarnings.push(message); }, @@ -1711,10 +1705,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo const binds = [...functionBinds.values()]; - yield* ensureDockerNamedVolume( - edgeRuntimeCacheVolume(projectId, slimEdgeRuntime).name, - projectId, - ); + yield* ensureDockerNamedVolume(edgeRuntimeCacheVolume(projectId).name, projectId); yield* ensureDockerNetwork(networkMode, projectId); const env = [ @@ -1767,7 +1758,6 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo }); const labels = dockerProjectLabels(projectId); - const serveMainDir = slimEdgeRuntime ? slimServeMainDir : dockerIoServeMainDir; const serveMainFile = `${serveMainDir}/index.ts`; const runtimeCommand = [ "edge-runtime", @@ -1778,14 +1768,6 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo ...buildFunctionsServeInspectArgs(input.inspectMode, input.inspectMain), ...(input.debug ? ["--verbose"] : []), ]; - if (slimEdgeRuntime && dockerMultilineEnvScript !== undefined) { - return yield* Effect.fail( - new SlimEdgeRuntimeMultilineSecretError({ - message: - "SUPABASE_USE_SLIM_IMAGES cannot source multiline function secrets: the slim edge-runtime image has no shell. Unset the flag, or remove newline-containing values from the functions env file.", - }), - ); - } const serveMainTemplate = yield* Effect.promise(() => getLegacyFunctionsServeMainTemplate()); // Streamed in via `docker cp` between create and start: embedding the template in the // `sh -c` argv hits Windows ENAMETOOLONG (#5711), and a single-file host bind mounts as @@ -1823,14 +1805,11 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo ...(input.inspectMode === undefined ? [] : ["-p", `${input.config.edgeRuntimeInspectorPort}:${dockerRuntimeInspectorPort}`]), - ...(slimEdgeRuntime ? [] : ["--entrypoint", "sh"]), + "--entrypoint", + "sh", input.image, - ...(slimEdgeRuntime - ? runtimeCommand.slice(1) - : [ - "-c", - buildServeEntrypointCommand(runtimeCommand, dockerMultilineEnvScript?.scriptPath), - ]), + "-c", + buildServeEntrypointCommand(runtimeCommand, dockerMultilineEnvScript?.scriptPath), ]; // The container must exist for `docker cp` to have a target, and must not be running diff --git a/apps/cli/src/shared/services/slim-images.ts b/apps/cli/src/shared/services/slim-images.ts index 18a7814af9..ae37549c15 100644 --- a/apps/cli/src/shared/services/slim-images.ts +++ b/apps/cli/src/shared/services/slim-images.ts @@ -124,7 +124,7 @@ export function isSlimImageRef(image: string): boolean { /** * True when the flag is on AND `image` is a slim ghcr ref. Spec builders and * one-shot jobs use this so a ghcr-shaped override with the flag off stays on - * the docker.io contract (same gate as {@link legacyIsSlimPostgresImage}). + * the docker.io contract. */ export function usesSlimImageRuntime(image: string): boolean { return slimImagesEnabled() && isSlimImageRef(image);