From 9d89f37559c61cb9349d0d797e65e8c2d8ae229e Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 28 Aug 2026 18:49:53 +0200 Subject: [PATCH 1/7] 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 local-stack Docker images from the slim ghcr.io/supabase/cli/ builds instead of the docker.io images pinned in the embedded Dockerfile. Services with no slim build stay on docker.io. With the flag unset, behavior is unchanged. Co-authored-by: Cursor --- apps/cli-go/pkg/config/templates/Dockerfile | 10 +- .../legacy-pgdelta.seam.integration.test.ts | 45 ++++- .../db/shared/legacy-pgdelta.seam.layer.ts | 10 +- .../db/start/start.integration.test.ts | 20 ++ .../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 + .../edge-runtime.service.integration.test.ts | 50 ++++- .../commands/start/services/gotrue.service.ts | 34 ++-- .../services/gotrue.service.unit.test.ts | 20 +- .../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 | 44 +++-- .../services/storage.service.unit.test.ts | 32 +++- .../commands/start/services/studio.service.ts | 25 ++- .../services/studio.service.unit.test.ts | 15 +- .../start/services/supavisor.service.ts | 35 ++-- .../commands/start/services/vector.service.ts | 47 +++-- .../services/vector.service.unit.test.ts | 29 +++ .../legacy/commands/start/start.handler.ts | 39 ++-- .../commands/start/start.integration.test.ts | 8 +- .../start/start.services.unit.test.ts | 63 ++++++- .../start/start.slim-images.e2e.test.ts | 160 ++++++++++++++++ .../legacy/shared/db-bootstrap/db-setup.ts | 9 +- .../shared/db-bootstrap/db-setup.unit.test.ts | 40 ++++ .../shared/db-bootstrap/pinned-image.ts | 21 ++- .../db-bootstrap/pinned-image.unit.test.ts | 79 ++++++++ .../shared/db-bootstrap/postgres.service.ts | 89 ++++++--- .../postgres.service.unit.test.ts | 21 ++- .../shared/db-bootstrap/slim-runtime.ts | 50 +++++ .../db-bootstrap/slim-runtime.unit.test.ts | 39 ++++ .../shared/db-bootstrap/start-database.ts | 34 ++-- apps/cli/src/legacy/shared/legacy-db-image.ts | 34 ++-- .../shared/legacy-db-image.unit.test.ts | 54 +++++- .../legacy/shared/legacy-docker-registry.ts | 16 ++ .../legacy-docker-registry.unit.test.ts | 48 +++++ .../shared/legacy-edge-runtime-image.ts | 67 ++++--- .../legacy-edge-runtime-image.unit.test.ts | 123 ++++++++++++- ...e-runtime-script.layer.integration.test.ts | 21 +++ .../legacy-edge-runtime-script.layer.ts | 6 +- .../shared/legacy-pg-dump.run.unit.test.ts | 83 +++++++++ .../src/legacy/shared/legacy-status-values.ts | 21 ++- .../shared/legacy-status-values.unit.test.ts | 39 +++- apps/cli/src/shared/functions/deploy.ts | 21 +-- .../src/shared/functions/deploy.unit.test.ts | 41 ++++- apps/cli/src/shared/functions/download.ts | 92 +++++----- .../src/shared/functions/functions-docker.ts | 31 +++- .../functions/functions-docker.unit.test.ts | 18 ++ .../src/shared/functions/functions.shared.ts | 29 ++- .../functions/functions.shared.unit.test.ts | 59 ++++++ .../functions/serve-main-offline.e2e.test.ts | 8 +- apps/cli/src/shared/functions/serve.ts | 11 +- .../src/shared/services/dockerfile-images.ts | 14 +- .../src/shared/services/services.shared.ts | 34 +++- .../services/services.shared.unit.test.ts | 67 ++++++- apps/cli/src/shared/services/slim-images.ts | 131 ++++++++++++++ .../shared/services/slim-images.unit.test.ts | 171 ++++++++++++++++++ apps/cli/tests/helpers/legacy-mocks.ts | 21 +++ packages/stack/package.json | 1 + 65 files changed, 2259 insertions(+), 348 deletions(-) create mode 100644 apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/pinned-image.unit.test.ts 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 create mode 100644 apps/cli/src/legacy/shared/legacy-pg-dump.run.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-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index fa96d9b4d4..032515d6f7 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -1,5 +1,5 @@ # Exposed for updates by .github/dependabot.yml -FROM supabase/postgres:17.6.1.165 AS pg +FROM supabase/postgres:17.6.1.166 AS pg # Append to ServiceImages when adding new dependencies below FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.30.2 AS mailpit @@ -9,11 +9,11 @@ FROM supabase/studio:2026.08.24-sha-8ec45b2 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.3 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector -FROM supabase/supavisor:2.9.7 AS supavisor +FROM supabase/supavisor:2.9.10 AS supavisor FROM supabase/gotrue:v2.196.0 AS gotrue -FROM supabase/realtime:v2.129.9 AS realtime -FROM supabase/storage-api:v1.71.0 AS storage -FROM supabase/logflare:1.50.6 AS logflare +FROM supabase/realtime:v2.129.3 AS realtime +FROM supabase/storage-api:v1.72.1 AS storage +FROM supabase/logflare:1.50.4 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ FROM supabase/migra:3.0.1663481299 AS migra diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts index 3132d010a5..0c1a590849 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { afterEach, vi } from "vitest"; import { mockLegacyCliSettings, @@ -115,12 +116,17 @@ const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { function setup( workdir: string, - opts: { readonly failCreate?: boolean; readonly dbInspectFailsWith?: string } = {}, + opts: { + readonly failCreate?: boolean; + readonly dbInspectFailsWith?: string; + readonly dbInspectImage?: string; + } = {}, ) { const out = mockOutput(); const shadowSpawner = mockLegacyShadowContainerCliSpawner({ failCreate: opts.failCreate, dbInspectFailsWith: opts.dbInspectFailsWith, + dbInspectImage: opts.dbInspectImage, }); const dbConnection = fakeShadowDbConnection(); const docker = fakeShadowSetupDocker(); @@ -272,3 +278,40 @@ describe("legacyDeclarativeSeamLayer.ensureLocalDatabaseStarted", () => { }, ); }); + +describe("legacyDeclarativeSeamLayer.ensureLocalPostgresImageCurrent", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.effect( + "flags a running docker.io container as stale against a slim-flagged expectation, even on a matching tag", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-seam-")); + const { layer } = setup(dir, { dbInspectImage: "supabase/postgres:17.6.1.165" }); + return Effect.gen(function* () { + const seam = yield* LegacyDeclarativeSeam; + const exit = yield* seam.ensureLocalPostgresImageCurrent().pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const error = failError(exit); + expect(error).toBeInstanceOf(LegacyDeclarativeShadowDbError); + expect((error as LegacyDeclarativeShadowDbError).message).toContain( + "local Postgres container image is stale", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("passes when the running container matches the expected image's family and tag", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-seam-")); + const { layer } = setup(dir, { dbInspectImage: "supabase/postgres:17.6.1.165" }); + return Effect.gen(function* () { + const seam = yield* LegacyDeclarativeSeam; + const exit = yield* seam.ensureLocalPostgresImageCurrent().pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index f4be9856ba..9cd91a5c73 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -7,6 +7,7 @@ import { legacyResolveDbImage } from "../../../shared/legacy-db-image.ts"; import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; import { legacyIsDockerDaemonUnreachable } from "../../../shared/legacy-docker-suggest.ts"; +import { isSlimImageRef } from "../../../../shared/services/slim-images.ts"; import { legacyIsLocalDbRunning } from "../../../shared/db-bootstrap/local-db-running.ts"; import { legacyStartLocalDatabase } from "../../../shared/db-bootstrap/start-local-database.ts"; import { @@ -262,7 +263,14 @@ export const legacyDeclarativeSeamLayer = Layer.effect( const expected = legacyGetRegistryImageUrl(image).trim(); const actualTag = dockerImageTag(actual); const expectedTag = dockerImageTag(expected); - if (actualTag.length === 0 || expectedTag.length === 0 || actualTag === expectedTag) { + // Slim refs never go through a registry mirror, so a family mismatch + // (e.g. a docker.io container satisfying a ghcr.io/supabase/cli + // expectation) is stale even when the tags happen to match. + const familyMismatch = isSlimImageRef(expected) !== isSlimImageRef(actual); + if ( + !familyMismatch && + (actualTag.length === 0 || expectedTag.length === 0 || actualTag === expectedTag) + ) { return; } return yield* Effect.fail( diff --git a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts index fafda720a6..99841ac409 100644 --- a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -389,6 +389,7 @@ const currentBranchPath = (workdir: string) => describe("legacy db start", () => { afterEach(() => { delete process.env["SUPABASE_NETWORK_ID"]; + vi.unstubAllEnvs(); }); it.live("reports an already-running database without starting a container", () => { @@ -682,6 +683,25 @@ describe("legacy db start", () => { }, ); + it.live( + "--from-backup under SUPABASE_USE_SLIM_IMAGES uses the same restore entrypoint as docker.io", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); + return Effect.gen(function* () { + yield* legacyDbStart(flags("/abs/host/backup.sql")).pipe(Effect.provide(layer)); + const args = createArgs(child.spawned); + expect(args).not.toBeUndefined(); + const script = args?.[(args?.indexOf("-c") ?? -1) + 1]; + expect(script).toContain("/docker-entrypoint-initdb.d/migrate.sh"); + expect(bindsFromCreateArgs(args ?? [])).toContain( + "/abs/host/backup.sql:/etc/backup.sql:ro", + ); + expect(dbSetupJobCalls(child.spawned)).toHaveLength(0); + }).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }, + ); + it.live("resolves a relative --from-backup against the caller cwd, not the workdir", () => { const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()), diff --git a/apps/cli/src/legacy/commands/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/services/edge-runtime.service.integration.test.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts index 913582d235..2462482d95 100644 --- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "@effect/vitest"; import { edgeRuntimeNofileUlimit } from "@supabase/stack/effect"; import { Deferred, Effect, Exit, Sink, Stream } from "effect"; import { type ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { beforeEach } from "vitest"; +import { afterEach, beforeEach, vi } from "vitest"; import { useLegacyTempWorkdir } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -130,6 +130,10 @@ describe("legacyStartEdgeRuntimeContainer", () => { mkdirSync(join(tempWorkdir.current, "supabase", "functions"), { recursive: true }); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it.effect( "sends the real internal db url (db container name, port 5432, config.db.password) — NOT functions serve's `db`-alias default", () => @@ -346,6 +350,50 @@ describe("legacyStartEdgeRuntimeContainer", () => { }), ); + it.effect( + "slim edge-runtime uses the docker.io entrypoint, /root main service, and shared cache volume", + () => + Effect.gen(function* () { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const mock = mockDockerSpawner(); + const out = mockOutput(); + const input = { + ...baseInput(tempWorkdir.current), + image: "ghcr.io/supabase/cli/edge-runtime:v1.74.2", + }; + + yield* legacyStartEdgeRuntimeContainer(input).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const createArgs = mock.runCall!.args; + expect(createArgs).toContain("--entrypoint"); + expect(createArgs).toContain("sh"); + const script = createArgs.at(-1); + expect(script).toContain("--main-service=/root"); + expect(script).not.toContain("--main-service=/tmp"); + + const volumeCreate = mock.calls.find((call) => call.args[0] === "volume"); + expect(volumeCreate?.args.at(-1)).toBe("supabase_edge_runtime_proj"); + expect(createArgs).not.toContain("supabase_edge_runtime_slim_proj:/home/nonroot:rw"); + + const cp = mock.calls.find((call) => call.args[0] === "cp"); + expect(cp?.args).toEqual(["cp", "-", "supabase_edge_runtime_proj:/"]); + const stdin = cp?.stdin; + expect(Stream.isStream(stdin)).toBe(true); + if (!Stream.isStream(stdin)) return yield* Effect.die("docker cp stdin was not a stream"); + const chunks = yield* Stream.runCollect(stdin); + expect(chunks).toHaveLength(1); + const archiveBytes = chunks[0]; + if (!(archiveBytes instanceof Uint8Array)) { + return yield* Effect.die("docker cp stdin did not contain archive bytes"); + } + const files = yield* Effect.promise(() => new Bun.Archive(archiveBytes).files()); + expect([...files.keys()]).toEqual(["root/index.ts"]); + }), + ); + it.effect( "surfaces docker's own stderr verbatim and never reaches cp/start when docker create fails", () => diff --git a/apps/cli/src/legacy/commands/start/services/gotrue.service.ts b/apps/cli/src/legacy/commands/start/services/gotrue.service.ts index 976eef8a7f..8fb978d503 100644 --- a/apps/cli/src/legacy/commands/start/services/gotrue.service.ts +++ b/apps/cli/src/legacy/commands/start/services/gotrue.service.ts @@ -54,6 +54,7 @@ import { import { LEGACY_DEFAULT_SIGNING_KEY } from "../../../shared/legacy-go-jwt.ts"; import type { LegacyResolvedAuthEmail } from "../../../shared/legacy-local-config-values.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { legacyUsesSlimRuntime } from "../../../shared/db-bootstrap/slim-runtime.ts"; import { legacyStartInternalDbPassword, legacyStartInternalDbUrl, @@ -645,6 +646,7 @@ export function legacyBuildGotrueContainerSpec( const dbHost = legacyServiceContainerName("db", input.projectId); const dbPassword = legacyStartInternalDbPassword(input.dbUrl); const env = legacyBuildGotrueEnv({ ...input.env, dbHost, dbPassword }); + const slim = legacyUsesSlimRuntime(input.image); return { image: input.image, @@ -652,19 +654,25 @@ export function legacyBuildGotrueContainerSpec( env, binds: [], exposedPorts: [{ containerPort: LEGACY_GOTRUE_PORT }], - healthcheck: { - test: [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - `http://127.0.0.1:${LEGACY_GOTRUE_PORT}/health`, - ], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - }, + // Distroless slim auth has no wget/curl/sh. Omitting the Docker healthcheck + // makes `legacyCheckContainerReady` treat `Running` as ready (same as PostgREST). + ...(slim + ? {} + : { + healthcheck: { + test: [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + `http://127.0.0.1:${LEGACY_GOTRUE_PORT}/health`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + }), restartPolicy: "unless-stopped", networkId: input.networkId, networkAliases: [LEGACY_GOTRUE_CONTAINER_SUFFIX], diff --git a/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts index 65450f6a0a..191d936c38 100644 --- a/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { legacyBuildGotrueContainerSpec, @@ -11,6 +11,10 @@ import { type LegacyGotrueWebauthnInput, } from "./gotrue.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + // Every field not asserted by a specific subtest below reflects the // default config's own values. const baseEnvInput: LegacyBuildGotrueEnvInput = { @@ -708,4 +712,18 @@ describe("legacyBuildGotrueContainerSpec", () => { "postgresql://supabase_auth_admin:secret@supabase_db_proj:5432/postgres", ); }); + + test("omits the wget healthcheck only when the slim flag is on", () => { + const input = { + image: "ghcr.io/supabase/cli/auth:v2.196.0", + projectId: "proj", + networkId: "supabase_network_proj", + dbUrl: "postgresql://postgres:secret@127.0.0.1:54322/postgres", + env: baseEnvInput, + }; + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(legacyBuildGotrueContainerSpec(input).healthcheck?.test[1]).toBe("wget"); + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + expect(legacyBuildGotrueContainerSpec(input).healthcheck).toBeUndefined(); + }); }); diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.ts index d51ad00fb1..e96cd19fe5 100644 --- a/apps/cli/src/legacy/commands/start/services/logflare.service.ts +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.ts @@ -19,6 +19,10 @@ import { join } from "node:path"; import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { + legacySlimBusyboxWgetHealthcheck, + legacyUsesSlimRuntime, +} from "../../../shared/db-bootstrap/slim-runtime.ts"; /** The Logflare network alias — also this service's `containerSuffix` in `LEGACY_SERVICE_CATALOG`. */ const LEGACY_LOGFLARE_CONTAINER_SUFFIX = "analytics"; @@ -146,23 +150,41 @@ export function legacyBuildLogflareContainerSpec( env.POSTGRES_BACKEND_SCHEMA = "_analytics"; } + const slim = legacyUsesSlimRuntime(input.image); + return { image: input.image, containerName: legacyServiceContainerName(LEGACY_LOGFLARE_CONTAINER_SUFFIX, input.projectId), hostname: "127.0.0.1", env, - entrypoint: "sh", - cmd: ["-c", LEGACY_LOGFLARE_ENTRYPOINT_SCRIPT], + ...(slim + ? {} + : { + entrypoint: "sh", + cmd: ["-c", LEGACY_LOGFLARE_ENTRYPOINT_SCRIPT], + }), binds, exposedPorts: [{ containerPort: "4000" }], ports: [{ hostPort: String(input.port), containerPort: "4000" }], - healthcheck: { - test: ["CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "http://127.0.0.1:4000/health"], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - startPeriodSeconds: 10, - }, + healthcheck: slim + ? legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:4000/health", { + startPeriodSeconds: 10, + }) + : { + test: [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "http://127.0.0.1:4000/health", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + startPeriodSeconds: 10, + }, restartPolicy: "unless-stopped", networkId: input.networkId, networkAliases: [LEGACY_LOGFLARE_CONTAINER_SUFFIX], diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts index 33b5db08cb..c9dd72a480 100644 --- a/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts @@ -1,12 +1,16 @@ import { join } from "node:path"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { legacyBuildLogflareContainerSpec, type LegacyLogflareContainerSpecInput, } from "./logflare.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + const base: LegacyLogflareContainerSpecInput = { image: "supabase/logflare:1.0.0", projectId: "proj", @@ -117,4 +121,23 @@ describe("legacyBuildLogflareContainerSpec", () => { }); expect(spec.binds).toEqual([`${join("/workdir", "")}:/opt/app/rel/logflare/bin/gcloud.json`]); }); + + test("keeps the image entrypoint and uses busybox wget on a slim analytics image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildLogflareContainerSpec({ + ...base, + image: "ghcr.io/supabase/cli/analytics:v1.50.4", + }); + expect(spec.entrypoint).toBeUndefined(); + expect(spec.cmd).toBeUndefined(); + expect(spec.healthcheck?.test).toEqual([ + "CMD", + "/bin/busybox", + "wget", + "-q", + "--spider", + "http://127.0.0.1:4000/health", + ]); + expect(spec.healthcheck?.startPeriodSeconds).toBe(10); + }); }); diff --git a/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts b/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts index 0d1d14e27c..d90de80da9 100644 --- a/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts +++ b/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts @@ -16,6 +16,7 @@ */ import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { legacyUsesSlimRuntime } from "../../../shared/db-bootstrap/slim-runtime.ts"; /** The hardcoded pg-meta listen port (`PG_META_PORT=8080`) — never configurable. */ const PG_META_PORT = 8080; @@ -64,15 +65,21 @@ export function legacyBuildPgMetaContainerSpec( PG_META_DB_PASSWORD: input.dbPassword, }, binds: [], - healthcheck: { - test: [ - "CMD-SHELL", - `node --eval="fetch('http://127.0.0.1:${PG_META_PORT}/health').then((r) => {if (!r.ok) throw new Error(r.status)})"`, - ], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - }, + // Distroless slim pg-meta has no /bin/sh; Docker CLI healthchecks are always + // CMD-SHELL. Omitting makes `legacyCheckContainerReady` treat Running as ready. + ...(legacyUsesSlimRuntime(input.image) + ? {} + : { + healthcheck: { + test: [ + "CMD-SHELL", + `node --eval="fetch('http://127.0.0.1:${PG_META_PORT}/health').then((r) => {if (!r.ok) throw new Error(r.status)})"`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + }), restartPolicy: "unless-stopped", networkId: input.networkId, networkAliases: PG_META_NETWORK_ALIASES, diff --git a/apps/cli/src/legacy/commands/start/services/pg-meta.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/pg-meta.service.unit.test.ts index a63048a97f..85221dbdfc 100644 --- a/apps/cli/src/legacy/commands/start/services/pg-meta.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/pg-meta.service.unit.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { legacyBuildPgMetaContainerSpec } from "./pg-meta.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("legacyBuildPgMetaContainerSpec", () => { test("assembles the full container spec from resolved inputs", () => { const spec = legacyBuildPgMetaContainerSpec({ @@ -60,4 +64,19 @@ describe("legacyBuildPgMetaContainerSpec", () => { expect(spec.env["PG_META_DB_PASSWORD"]).toBe("hunter2"); expect(spec.healthcheck?.test[1]).toContain("127.0.0.1:8080"); }); + + test("omits the Docker healthcheck on a slim distroless pg-meta image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildPgMetaContainerSpec({ + image: "ghcr.io/supabase/cli/pgmeta:v0.98.0", + containerName: "supabase_pg_meta_proj", + dbHost: "supabase_db_proj", + dbPort: 5432, + dbUser: "postgres", + dbPassword: "postgres", + dbName: "postgres", + networkId: "supabase_network_proj", + }); + expect(spec.healthcheck).toBeUndefined(); + }); }); diff --git a/apps/cli/src/legacy/commands/start/services/realtime.service.ts b/apps/cli/src/legacy/commands/start/services/realtime.service.ts index 895c82f9d9..127d7405e5 100644 --- a/apps/cli/src/legacy/commands/start/services/realtime.service.ts +++ b/apps/cli/src/legacy/commands/start/services/realtime.service.ts @@ -17,6 +17,10 @@ import { legacyBuildRealtimeEnv, } from "../../../shared/db-bootstrap/realtime-env.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { + legacySlimBusyboxWgetHealthcheck, + legacyUsesSlimRuntime, +} from "../../../shared/db-bootstrap/slim-runtime.ts"; import { legacyStartInternalDbPassword } from "../../../shared/db-bootstrap/internal-db-connection.ts"; export interface LegacyRealtimeContainerSpecInput { @@ -57,24 +61,28 @@ export function legacyBuildRealtimeContainerSpec( env, binds: [], exposedPorts: [{ containerPort: "4000" }], - healthcheck: { - // Podman splits command by spaces unless quoted, but curl's header can't be - // quoted, hence this exec-form `test` array. - test: [ - "CMD", - "curl", - "-sSfL", - "--head", - "-o", - "/dev/null", - "-H", - `Host:${LEGACY_REALTIME_TENANT_ID}`, - "http://127.0.0.1:4000/api/ping", - ], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - }, + healthcheck: legacyUsesSlimRuntime(input.image) + ? legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:4000/api/ping", { + header: `Host:${LEGACY_REALTIME_TENANT_ID}`, + }) + : { + // Podman splits command by spaces unless quoted, but curl's header can't be + // quoted, hence this exec-form `test` array. + test: [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "-H", + `Host:${LEGACY_REALTIME_TENANT_ID}`, + "http://127.0.0.1:4000/api/ping", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, restartPolicy: "unless-stopped", networkId: input.networkId, // Network aliases: `realtime` plus the tenant id. diff --git a/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts index 3aca580e8b..5c2b987ea8 100644 --- a/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts @@ -1,10 +1,14 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { legacyBuildRealtimeContainerSpec, type LegacyRealtimeContainerSpecInput, } from "./realtime.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("legacyBuildRealtimeContainerSpec", () => { const input: LegacyRealtimeContainerSpecInput = { projectId: "proj", @@ -65,4 +69,22 @@ describe("legacyBuildRealtimeContainerSpec", () => { }); expect(spec.env["DB_PASSWORD"]).toBe("another-secret"); }); + + test("uses busybox wget for the healthcheck on a slim realtime image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildRealtimeContainerSpec({ + ...input, + image: "ghcr.io/supabase/cli/realtime:v2.129.3", + }); + expect(spec.healthcheck?.test).toEqual([ + "CMD", + "/bin/busybox", + "wget", + "-q", + "--spider", + "--header", + "Host:realtime-dev", + "http://127.0.0.1:4000/api/ping", + ]); + }); }); diff --git a/apps/cli/src/legacy/commands/start/services/storage.service.ts b/apps/cli/src/legacy/commands/start/services/storage.service.ts index e38aa9d1f2..55d283180f 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.ts @@ -37,8 +37,8 @@ import type { CliConfig } from "@supabase/config"; import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; -import { ramInBytes } from "../../../shared/legacy-size-units.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { ramInBytes } from "../../../shared/legacy-size-units.ts"; import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; import { legacyStartInternalDbUrl, @@ -150,6 +150,10 @@ export function legacyBuildStorageEnv(input: LegacyStorageEnvInput): Record { + vi.unstubAllEnvs(); +}); + const baseEnvInput: LegacyStorageEnvInput = { targetMigration: "", anonKey: "anon-key", @@ -253,4 +257,30 @@ describe("legacyBuildStorageContainerSpec", () => { "postgresql://postgres:postgres@supabase_db_proj:5432/postgres", ); }); + + test("emits the wget Docker healthcheck on a slim storage image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildStorageContainerSpec({ + ...input, + image: "ghcr.io/supabase/cli/storage:v1.70.3", + }); + expect(spec.healthcheck?.test).toEqual([ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:5000/status", + ]); + }); + + test("mounts the named volume at /mnt on a slim image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildStorageContainerSpec({ + ...input, + image: "ghcr.io/supabase/cli/storage:v1.70.3", + }); + expect(spec.binds).toEqual(["supabase_storage_proj:/mnt"]); + expect(spec.env["FILE_STORAGE_BACKEND_PATH"]).toBe("/mnt"); + }); }); diff --git a/apps/cli/src/legacy/commands/start/services/studio.service.ts b/apps/cli/src/legacy/commands/start/services/studio.service.ts index 291b575cc0..15789dbb0d 100644 --- a/apps/cli/src/legacy/commands/start/services/studio.service.ts +++ b/apps/cli/src/legacy/commands/start/services/studio.service.ts @@ -24,6 +24,7 @@ import { join } from "node:path"; import { legacyToDockerPath } from "../../../shared/legacy-docker-path.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { legacyUsesSlimRuntime } from "../../../shared/db-bootstrap/slim-runtime.ts"; /** Container-internal port Studio listens on — hardcoded, never configurable. */ const STUDIO_CONTAINER_PORT = 3000; @@ -186,15 +187,21 @@ export function legacyBuildStudioContainerSpec( containerName: input.containerName, env: legacyBuildStudioEnv({ ...input.env, containerSnippetsPath }), binds, - healthcheck: { - test: [ - "CMD-SHELL", - `node --eval="fetch('http://127.0.0.1:${STUDIO_CONTAINER_PORT}/api/platform/profile').then((r) => {if (!r.ok) throw new Error(r.status)})"`, - ], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - }, + // Distroless slim studio has no /bin/sh; Docker CLI healthchecks are always + // CMD-SHELL. Omitting makes `legacyCheckContainerReady` treat Running as ready. + ...(legacyUsesSlimRuntime(input.image) + ? {} + : { + healthcheck: { + test: [ + "CMD-SHELL", + `node --eval="fetch('http://127.0.0.1:${STUDIO_CONTAINER_PORT}/api/platform/profile').then((r) => {if (!r.ok) throw new Error(r.status)})"`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + }), ports: [{ hostPort: String(input.port), containerPort: String(STUDIO_CONTAINER_PORT) }], restartPolicy: "unless-stopped", networkId: input.networkId, diff --git a/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts index 80728d823d..fe534cd300 100644 --- a/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { legacyBuildStudioContainerSpec, @@ -6,6 +6,10 @@ import { type LegacyBuildStudioEnvInput, } from "./studio.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + const baseEnvInput: LegacyBuildStudioEnvInput = { dbPassword: "postgres", workdir: "/project", @@ -169,4 +173,13 @@ describe("legacyBuildStudioContainerSpec", () => { expect(spec.binds).toEqual(["/project/supabase/snippets:/project/supabase/snippets:rw"]); }); + + test("omits the Docker healthcheck on a slim distroless studio image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildStudioContainerSpec({ + ...baseSpecInput, + image: "ghcr.io/supabase/cli/studio:2026.08.17-sha-0c1da8f", + }); + expect(spec.healthcheck).toBeUndefined(); + }); }); diff --git a/apps/cli/src/legacy/commands/start/services/supavisor.service.ts b/apps/cli/src/legacy/commands/start/services/supavisor.service.ts index d7aeb89a30..e2d0a45ac5 100644 --- a/apps/cli/src/legacy/commands/start/services/supavisor.service.ts +++ b/apps/cli/src/legacy/commands/start/services/supavisor.service.ts @@ -40,6 +40,10 @@ import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { + legacySlimBusyboxWgetHealthcheck, + legacyUsesSlimRuntime, +} from "../../../shared/db-bootstrap/slim-runtime.ts"; import { legacyRenderStartPoolerExs, type LegacyStartPoolerExsFields, @@ -180,20 +184,23 @@ export function legacyBuildSupavisorContainerSpec( { containerPort: LEGACY_SUPAVISOR_TRANSACTION_PORT }, ], ports: [{ hostPort: String(input.port), containerPort: dockerPort }], - healthcheck: { - test: [ - "CMD", - "curl", - "-sSfL", - "--head", - "-o", - "/dev/null", - "http://127.0.0.1:4000/api/health", - ], - intervalSeconds: 10, - timeoutSeconds: 2, - retries: 3, - }, + // The slim supavisor image is distroless plus /bin/busybox (no curl). + healthcheck: legacyUsesSlimRuntime(input.image) + ? legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:4000/api/health") + : { + test: [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "http://127.0.0.1:4000/api/health", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, restartPolicy: "unless-stopped", networkId: input.networkId, networkAliases: [LEGACY_SUPAVISOR_CONTAINER_SUFFIX], diff --git a/apps/cli/src/legacy/commands/start/services/vector.service.ts b/apps/cli/src/legacy/commands/start/services/vector.service.ts index d1813c2604..1606b56f27 100644 --- a/apps/cli/src/legacy/commands/start/services/vector.service.ts +++ b/apps/cli/src/legacy/commands/start/services/vector.service.ts @@ -35,6 +35,10 @@ import * as ChildProcess from "effect/unstable/process/ChildProcess"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { + legacySlimBusyboxWgetHealthcheck, + legacyUsesSlimRuntime, +} from "../../../shared/db-bootstrap/slim-runtime.ts"; import { legacyRenderStartVectorYaml } from "../lib/template-render.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -344,18 +348,33 @@ export function legacyBuildVectorContainerSpec( dbId: input.dbId, }); - return { - image: input.image, - containerName: input.containerName, - env: input.dockerSocketPlan.env, - entrypoint: "sh", - cmd: ["-c", legacyBuildVectorEntrypointScript(vectorYaml, input.logflareId)], - binds: input.dockerSocketPlan.binds, - healthcheck: LEGACY_VECTOR_HEALTHCHECK, - restartPolicy: "unless-stopped", - securityOpt: input.dockerSocketPlan.securityOpt, - networkId: input.networkId, - networkAliases: LEGACY_VECTOR_NETWORK_ALIASES, - labels: {}, - }; + return legacyUsesSlimRuntime(input.image) + ? { + image: input.image, + containerName: input.containerName, + env: input.dockerSocketPlan.env, + cmd: ["--config", "/etc/vector/vector.yaml"], + secretFiles: [{ containerPath: "/etc/vector/vector.yaml", content: vectorYaml }], + binds: input.dockerSocketPlan.binds, + healthcheck: legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:9001/health"), + restartPolicy: "unless-stopped", + securityOpt: input.dockerSocketPlan.securityOpt, + networkId: input.networkId, + networkAliases: LEGACY_VECTOR_NETWORK_ALIASES, + labels: {}, + } + : { + image: input.image, + containerName: input.containerName, + env: input.dockerSocketPlan.env, + entrypoint: "sh", + cmd: ["-c", legacyBuildVectorEntrypointScript(vectorYaml, input.logflareId)], + binds: input.dockerSocketPlan.binds, + healthcheck: LEGACY_VECTOR_HEALTHCHECK, + restartPolicy: "unless-stopped", + securityOpt: input.dockerSocketPlan.securityOpt, + networkId: input.networkId, + networkAliases: LEGACY_VECTOR_NETWORK_ALIASES, + labels: {}, + }; } diff --git a/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts index 24762e5f5b..d3669438b7 100644 --- a/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/vector.service.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, test } from "@effect/vitest"; +import { afterEach, vi } from "vitest"; import { Deferred, Effect, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -15,6 +16,10 @@ import { type LegacyVectorDockerSocketPlan, } from "./vector.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + /** Matches the standing `mockSpawner` shape in `image-prepull.unit.test.ts`. */ function mockSpawner( handler: (args: ReadonlyArray) => { exitCode: number; stdout?: string; stderr?: string }, @@ -295,6 +300,30 @@ describe("legacyBuildVectorContainerSpec", () => { expect(script).toContain('"supabase_vector_proj"'); expect(script).toContain('.appname == "supabase_kong_proj"'); }); + + test("delivers vector.yaml via secretFiles and uses busybox wget on a slim image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildVectorContainerSpec({ + ...base, + image: "ghcr.io/supabase/cli/vector:0.53.0", + }); + expect(spec.entrypoint).toBeUndefined(); + expect(spec.cmd).toEqual(["--config", "/etc/vector/vector.yaml"]); + expect(spec.secretFiles).toEqual([ + { + containerPath: "/etc/vector/vector.yaml", + content: expect.stringContaining('"supabase_vector_proj"'), + }, + ]); + expect(spec.healthcheck?.test).toEqual([ + "CMD", + "/bin/busybox", + "wget", + "-q", + "--spider", + "http://127.0.0.1:9001/health", + ]); + }); }); describe("legacyResolveDockerDaemonHost", () => { diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 2d6034999d..07fcea01c1 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -1790,9 +1790,10 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // the same typed config error every other malformed-config path in // this handler already produces, matching the fail-fast-at-decode // behavior every other field validates with. + const resolvedServiceImage = resolveImage(image); const { spec, excludeFromHealthWatch } = yield* buildSpecForService( entry.service, - resolveImage(image), + resolvedServiceImage, ).pipe( Effect.catchDefect((defect) => Effect.fail( @@ -1980,18 +1981,26 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta projectRef: "", config: effectiveLocalStorageConfig, }); + // Shared by every gateway probe below (the bulk wait and the + // storage-only recheck), so both trust the same local Kong CA. + const withLocalKongCa = (effect: Effect.Effect) => + localKongCa === undefined + ? effect + : effect.pipe( + Effect.provideService( + FetchHttpClient.Fetch, + legacyStorageGatewayFetch(localKongCa), + ), + ); // Keep the synthetic value out of project dotenv resolution and container environments. legacyConfigureLoopbackProxyBypass(); - const healthResult = yield* legacyWaitForHealthyServices(spawner, [...started.keys()], { - postgrest: postgrestGateway, - edgeRuntime: edgeRuntimeGateway, - images: started, - }).pipe( - Effect.result, - localKongCa !== undefined - ? Effect.provideService(FetchHttpClient.Fetch, legacyStorageGatewayFetch(localKongCa)) - : (effect) => effect, - ); + const healthResult = yield* withLocalKongCa( + legacyWaitForHealthyServices(spawner, [...started.keys()], { + postgrest: postgrestGateway, + edgeRuntime: edgeRuntimeGateway, + images: started, + }), + ).pipe(Effect.result); if (Result.isFailure(healthResult)) { const error = healthResult.failure; if (flags.ignoreHealthCheck && legacyIsUnhealthyStartError(error)) { @@ -2012,10 +2021,10 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // `images` is intentionally the whole run's registry, not scoped to // this one-container watch list — the hint can only ever key off // containers that actually appear in this call's own failures. - const storageHealthResult = yield* legacyWaitForHealthyServices( - spawner, - [storageContainerId], - { images: started }, + const storageHealthResult = yield* withLocalKongCa( + legacyWaitForHealthyServices(spawner, [storageContainerId], { + images: started, + }), ).pipe(Effect.result); if (Result.isSuccess(storageHealthResult)) { const seedResult = yield* legacySeedBucketsRun({ diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts index 10de9bd618..23fbd53b4b 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -322,9 +322,12 @@ function freshVolumeRoute( function mockStorageBucketHttpClient() { const createdBucketRequests: Array = []; const createdBucketBodies: Array = []; + /** Every request in order, so a test can assert a readiness probe preceded seeding. */ + const requests: Array<{ method: string; url: string }> = []; const layer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => { + requests.push({ method: request.method, url: request.url }); if (request.method === "GET" && request.url.includes("/storage/v1/bucket")) { return Effect.succeed( HttpClientResponse.fromWeb( @@ -362,7 +365,7 @@ function mockStorageBucketHttpClient() { ); }), ); - return { layer, createdBucketRequests, createdBucketBodies }; + return { layer, createdBucketRequests, createdBucketBodies, requests }; } /** @@ -2645,6 +2648,9 @@ content_path = "./supabase/templates/custom_notice.html" return Effect.gen(function* () { yield* legacyStart(flags({ exclude: ["edge-runtime"] })); expect(http.createdBucketRequests).toHaveLength(1); + // docker.io Storage carries its own Docker healthcheck, so readiness + // never goes through the gateway. + expect(http.requests.some((entry) => entry.url.includes("/storage/v1/status"))).toBe(false); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/start/start.services.unit.test.ts b/apps/cli/src/legacy/commands/start/start.services.unit.test.ts index 028dad8bed..a5f4b6b4f1 100644 --- a/apps/cli/src/legacy/commands/start/start.services.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/start.services.unit.test.ts @@ -1,12 +1,25 @@ import { CliConfigSchema, type CliConfig } from "@supabase/config"; import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; +import type { LocalServiceVersionOverrides } from "../../../shared/services/services.shared.ts"; +import { toSlimImage } from "../../../shared/services/slim-images.ts"; import { legacyServiceContainerIds, localDbContainerId } from "../../shared/legacy-docker-ids.ts"; import { LEGACY_SERVICE_CATALOG } from "../../shared/legacy-service-catalog.ts"; -import { legacyResolveStartGates, type LegacyStartGates } from "./start.gates.ts"; +import { + legacyResolveStartGates, + legacyResolveStartImagePlan, + type LegacyStartGates, +} from "./start.gates.ts"; import { LEGACY_START_SERVICES, legacyStartServiceMeta } from "./start.services.ts"; +const currentGotrue = dockerfileServiceImageRaw("gotrue"); +const currentLogflare = dockerfileServiceImageRaw("logflare"); +const currentVector = dockerfileServiceImageRaw("vector"); +const currentPooler = dockerfileServiceImageRaw("supavisor"); +const currentPoolerTag = currentPooler.split(":")[1] ?? ""; + describe("LEGACY_START_SERVICES", () => { it("has one row per LEGACY_SERVICE_CATALOG entry, in the catalog's startOrder", () => { expect(LEGACY_START_SERVICES).toHaveLength(LEGACY_SERVICE_CATALOG.length); @@ -212,3 +225,49 @@ describe("LEGACY_START_SERVICES enabledGate cross-check against start.gates.ts", expect(ungated.map((entry) => entry.service).toSorted()).toEqual(["postgres"]); }); }); + +describe("legacyResolveStartImagePlan under SUPABASE_USE_SLIM_IMAGES", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + const allGatesOpen: LegacyStartGates = { + kong: true, + gotrue: true, + mailpit: true, + realtime: true, + postgrest: true, + storage: true, + imgproxy: true, + logflare: true, + vector: true, + pgMeta: true, + studio: true, + supavisor: true, + edgeRuntime: true, + }; + + const imageFor = (service: string, serviceVersions: LocalServiceVersionOverrides = {}) => + legacyResolveStartImagePlan(allGatesOpen, serviceVersions).find( + (entry) => entry.service === service, + )?.image; + + it("plans docker.io images while the flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(imageFor("gotrue")).toBe(currentGotrue); + expect(imageFor("vector")).toBe(currentVector); + expect(imageFor("supavisor", { pooler: "2.0.0" })).toBe("supabase/supavisor:2.0.0"); + }); + + it("plans slim images when the flag is on, keeping unmapped services on docker.io", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(imageFor("gotrue")).toBe(toSlimImage("gotrue", currentGotrue)); + expect(imageFor("logflare")).toBe(toSlimImage("logflare", currentLogflare)); + expect(imageFor("vector")).toBe(toSlimImage("vector", currentVector)); + expect(imageFor("supavisor", { pooler: currentPoolerTag })).toBe( + toSlimImage("supavisor", currentPooler), + ); + expect(imageFor("supavisor", { pooler: "2.0.0" })).toBe("supabase/supavisor:2.0.0"); + expect(imageFor("kong")).toBe("library/kong:2.8.1"); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts b/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts new file mode 100644 index 0000000000..96546a0360 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts @@ -0,0 +1,160 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeAll, describe, expect, test } from "vitest"; + +import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; +import { toSlimImage } from "../../../shared/services/slim-images.ts"; +import { ensureImage, resolveDeadline } from "../../../../tests/helpers/docker-image.ts"; +import { + overrideStackPorts, + requireCliSuccess, + runSupabase, +} from "../../../../tests/helpers/cli.ts"; +import { + legacySanitizeProjectId, + legacyServiceContainerName, + localDbContainerId, +} from "../../shared/legacy-docker-ids.ts"; + +const execFileAsync = promisify(execFile); + +const START_TIMEOUT_MS = 280_000; +const SHORT_E2E_TIMEOUT_MS = 30_000; +const PULL_TIMEOUT_MS = 240_000; +const LIFECYCLE_OVERHEAD_MS = 90_000; + +const SLIM_ENV = { SUPABASE_USE_SLIM_IMAGES: "1" } as const; +/** Override an inherited dogfood/CI flag so docker.io starts stay on docker.io. */ +const DOCKER_IO_ENV = { SUPABASE_USE_SLIM_IMAGES: "" } as const; +const START_ARGS = ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"]; +const PULL_ALIASES = [ + "pg", + "gotrue", + "postgrest", + "realtime", + "storage", + "edgeruntime", + "pgmeta", + "mailpit", + "kong", +] as const; + +function latestImagesToPull(): ReadonlyArray { + const images = new Set(); + for (const alias of PULL_ALIASES) { + const raw = dockerfileServiceImageRaw(alias); + images.add(raw); + const slim = toSlimImage(alias, raw); + if (slim.startsWith("ghcr.io/supabase/cli/")) images.add(slim); + } + return [...images]; +} + +function readSectionPort(config: string, section: string): number { + const escaped = section.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = new RegExp(`^\\[${escaped}\\][\\s\\S]*?^port = (\\d+)`, "m").exec(config); + if (match?.[1] === undefined) { + throw new Error(`missing [${section}] port`); + } + return Number(match[1]); +} +async function containerImage(name: string): Promise { + const { stdout } = await execFileAsync("docker", [ + "inspect", + name, + "--format", + "{{.Config.Image}}", + ]); + return stdout.trim(); +} + +function expectedSlimImage(alias: string): string { + return toSlimImage(alias, dockerfileServiceImageRaw(alias)); +} + +async function pullLatestImage(image: string, deadline: number): Promise { + try { + await execFileAsync("docker", ["pull", image], { + timeout: Math.max(1, deadline - Date.now()), + }); + } catch { + await ensureImage(image, deadline); + } +} + +describe("supabase start slim images (e2e)", () => { + let projectDir: string | undefined; + + beforeAll(async () => { + const deadline = resolveDeadline(PULL_TIMEOUT_MS); + for (const image of latestImagesToPull()) { + await pullLatestImage(image, deadline); + } + }, PULL_TIMEOUT_MS + 10_000); + + afterEach(async () => { + if (projectDir === undefined) return; + await runSupabase(["stop", "--no-backup"], { + entrypoint: "legacy", + cwd: projectDir, + env: SLIM_ENV, + }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + projectDir = undefined; + }); + + test( + "starts the latest slim images, serves a function without a version pin, and keeps the Dockerfile tag", + { timeout: START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-slim-start-e2e-")); + const projectId = legacySanitizeProjectId(path.basename(projectDir)); + const edgeRuntimeContainer = legacyServiceContainerName("edge_runtime", projectId); + const dbContainer = localDbContainerId(projectId); + const storageContainer = legacyServiceContainerName("storage", projectId); + + const init = await runSupabase(["init"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, + env: DOCKER_IO_ENV, + }); + requireCliSuccess(init, "init"); + + const created = await runSupabase(["functions", "new", "hello", "--auth", "none"], { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, + env: { ...DOCKER_IO_ENV, SUPABASE_YES: "1" }, + }); + requireCliSuccess(created, "functions new"); + await overrideStackPorts(projectDir); + const config = await readFile(path.join(projectDir, "supabase", "config.toml"), "utf8"); + const apiPort = readSectionPort(config, "api"); + + const start = await runSupabase(START_ARGS, { + entrypoint: "legacy", + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + env: SLIM_ENV, + }); + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + + expect(await containerImage(dbContainer)).toBe(expectedSlimImage("pg")); + expect(await containerImage(storageContainer)).toBe(expectedSlimImage("storage")); + expect(await containerImage(edgeRuntimeContainer)).toBe(expectedSlimImage("edgeruntime")); + + const invoked = await fetch(`http://127.0.0.1:${apiPort}/functions/v1/hello`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Functions" }), + }); + const body = await invoked.text(); + expect(invoked.ok, body).toBe(true); + expect(JSON.parse(body)).toEqual({ message: "Hello Functions!" }); + }, + ); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts index a48d38eead..31c83a4aa1 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -19,7 +19,11 @@ * run-to-completion container on the SAME Docker network as `db` — Go's * `DockerStart` defaults `NetworkMode` to `utils.NetId` when unset, * `docker.go:379-383`), each gated on its own service's `enabled` flag and none - * of which touch `conn` directly: + * of which touch `conn` directly. Slim Realtime still runs the one-shot + * (user migrations need the tenant before long-running containers boot). + * Slim Storage has no `migrate-call.js`, so that job uses the docker.io + * storage image. Slim Auth still runs a one-shot `migrate` under the baked + * `auth` ENTRYPOINT. * - `initRealtimeJob` (`start.go:268-295`) — reuses * `./realtime-env.ts`'s `legacyBuildRealtimeEnv`, which builds * the byte-identical env-var literal Go's own `initRealtimeJob` embeds @@ -802,6 +806,9 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( const dbPassword = legacyStartInternalDbPassword(input.dbUrl); if (input.config.realtime.enabled) { + // Slim realtime's ENTRYPOINT (`tini` + `/app/entry.sh`) migrates, seeds + // when `SEED_SELF_HOST=true`, then `exec "$@"`. Passing only `cmd` (no + // entrypoint override) runs that one-shot before user migrations. yield* legacyRunStartMigrateJob(spawner, { image: input.images.realtime, networkId: input.networkId, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts index 5af2ee4ed0..d734a9091b 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts @@ -5,6 +5,7 @@ import type { CliConfig } from "@supabase/config"; import { CliConfigSchema } from "@supabase/config"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; +import { afterEach, vi } from "vitest"; import { Deferred, Effect, FileSystem, Layer, Path, Schema, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -268,6 +269,10 @@ const run = ( ); describe("legacyStartSetupLocalDatabase", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + describe("PG <= 14 vs PG >= 15 schema branch", () => { it.effect("PG14: execs globals + the PG14 initial schema, runs no one-shot docker jobs", () => { const workdir = makeWorkdir(); @@ -363,6 +368,41 @@ describe("legacyStartSetupLocalDatabase", () => { ); }); + it.effect( + "slim refs: runs realtime, storage, and auth one-shots on the resolved slim images", + () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run( + baseInput(workdir, session, { + majorVersion: 15, + images: { + realtime: "ghcr.io/supabase/cli/realtime:v2.129.3", + storage: "ghcr.io/supabase/cli/storage:v1.70.3", + auth: "ghcr.io/supabase/cli/auth:v2.196.0", + }, + }), + out, + docker, + ).pipe( + Effect.map(() => { + expect(docker.runs.map((job) => job.image)).toEqual([ + "ghcr.io/supabase/cli/realtime:v2.129.3", + "ghcr.io/supabase/cli/storage:v1.70.3", + "ghcr.io/supabase/cli/auth:v2.196.0", + ]); + expect(docker.runs[0]?.cmd?.[0]).toBe("/app/bin/realtime"); + expect(docker.runs[1]?.cmd).toEqual(["node", "dist/scripts/migrate-call.js"]); + expect(docker.runs[2]?.cmd).toEqual(["gotrue", "migrate"]); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + it.effect( "labels every one-shot job with the project's Docker labels, matching Go's DockerStart (review: Codex, PR #6022)", () => { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts index 89b70d18f1..0d417735bb 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts @@ -1,8 +1,8 @@ -import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; -import { - replaceImageTag, - type LocalServiceVersionName, - type LocalServiceVersionOverrides, +import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; +import { slimImageForCurrentPin } from "../../../shared/services/slim-images.ts"; +import type { + LocalServiceVersionName, + LocalServiceVersionOverrides, } from "../../../shared/services/services.shared.ts"; /** @@ -18,13 +18,18 @@ import { * start`'s own native container bootstrap became a second caller across the * `start`/`db` family boundary, see `apps/cli/CLAUDE.md`'s "Hoist Before You * Duplicate" rule. + * + * Slim-translate only the current Dockerfile pin. A historical `.temp` pin + * stays on docker.io — those slim tags are not published. */ export function legacyResolvePinnedImage( alias: string, localServiceName: LocalServiceVersionName, serviceVersions: LocalServiceVersionOverrides, ): string { - const baseImage = dockerfileServiceImage(alias); - const pinnedVersion = serviceVersions[localServiceName]; - return pinnedVersion === undefined ? baseImage : replaceImageTag(baseImage, pinnedVersion); + return slimImageForCurrentPin( + alias, + dockerfileServiceImageRaw(alias), + serviceVersions[localServiceName], + ); } diff --git a/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.unit.test.ts new file mode 100644 index 0000000000..57bf26555b --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.unit.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; +import { toSlimImage } from "../../../shared/services/slim-images.ts"; +import { legacyResolvePinnedImage } from "./pinned-image.ts"; + +const currentTag = (alias: string) => dockerfileServiceImageRaw(alias).split(":")[1] ?? ""; +const currentAuth = dockerfileServiceImageRaw("gotrue"); +const currentAuthTag = currentTag("gotrue"); +const currentPooler = dockerfileServiceImageRaw("supavisor"); +const currentPoolerTag = currentTag("supavisor"); +const currentPostgres = dockerfileServiceImageRaw("pg"); +const currentPostgresTag = currentTag("pg"); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("legacyResolvePinnedImage", () => { + it("resolves docker.io images while the slim flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(legacyResolvePinnedImage("gotrue", "auth", {})).toBe(currentAuth); + expect(legacyResolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" })).toBe( + "supabase/gotrue:v2.100.0", + ); + expect(legacyResolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" })).toBe( + "supabase/supavisor:2.0.0", + ); + }); + + it("resolves slim images when the flag is on and the pin is current", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(legacyResolvePinnedImage("gotrue", "auth", {})).toBe(toSlimImage("gotrue", currentAuth)); + expect(legacyResolvePinnedImage("gotrue", "auth", { auth: currentAuthTag })).toBe( + toSlimImage("gotrue", currentAuth), + ); + }); + + it("keeps a historical pin on docker.io", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(legacyResolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" })).toBe( + "supabase/gotrue:v2.100.0", + ); + expect(legacyResolvePinnedImage("storage", "storage", { storage: "v1.67.0" })).toBe( + "supabase/storage-api:v1.67.0", + ); + expect(legacyResolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" })).toBe( + "supabase/supavisor:2.0.0", + ); + }); + + it("normalizes a current pooler pin onto the slim tag scheme", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(legacyResolvePinnedImage("supavisor", "pooler", { pooler: currentPoolerTag })).toBe( + toSlimImage("supavisor", currentPooler), + ); + expect( + legacyResolvePinnedImage("supavisor", "pooler", { + pooler: currentPoolerTag.startsWith("v") + ? currentPoolerTag.slice(1) + : `v${currentPoolerTag}`, + }), + ).toBe(toSlimImage("supavisor", currentPooler)); + }); + + it("keeps a historical postgres pin on docker.io", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(legacyResolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" })).toBe( + "supabase/postgres:17.4.1.1", + ); + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + expect(legacyResolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" })).toBe( + "supabase/postgres:17.4.1.1", + ); + expect(legacyResolvePinnedImage("pg", "postgres", { postgres: currentPostgresTag })).toBe( + toSlimImage("pg", currentPostgres), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts index d740b853ba..156d56c6da 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts @@ -50,11 +50,31 @@ const LEGACY_POSTGRES_PASSWORD = "postgres"; */ const LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH = "/etc/postgresql-custom/pgsodium_root.key"; +/** + * The post-migration hook path: `supabase/postgres`'s bundled `migrate.sh` execs + * `psql -v ON_ERROR_STOP=1 -U supabase_admin -f /etc/postgresql.schema.sql` as + * its last step when the file exists. The docker.io entrypoint heredocs it + * (see {@link legacyPostgresEntrypointScriptPg15}). + */ +const LEGACY_POSTGRES_SCHEMA_SQL_PATH = "/etc/postgresql.schema.sql"; + /** Go's `container.HealthConfig` literals (`apps/cli-go/internal/db/start/start.go:85-90`). */ const LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS = 10; const LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS = 2; const LEGACY_POSTGRES_HEALTHCHECK_RETRIES = 3; +/** The docker.io image's healthcheck: `pg_isready` alone is a sufficient readiness probe. */ +const LEGACY_POSTGRES_HEALTHCHECK_TEST: ReadonlyArray = [ + "CMD", + "pg_isready", + "-U", + "postgres", + "-h", + "127.0.0.1", + "-p", + "5432", +]; + /** Go's `utils.DbAliases` (`apps/cli-go/internal/utils/config.go:36`). */ const LEGACY_POSTGRES_NETWORK_ALIASES: ReadonlyArray = ["db", "db.supabase.internal"]; @@ -137,9 +157,7 @@ export interface LegacyPostgresStartServiceInput { export function legacyPostgresSettingsToPostgresConfig( settings: CliConfig["db"]["settings"], ): string { - const defined = Object.fromEntries( - Object.entries(settings ?? {}).filter(([, value]) => value !== undefined), - ); + const defined = Object.fromEntries(legacyDefinedPostgresSettings(settings)); if (Object.keys(defined).length === 0) { return LEGACY_POSTGRES_CONFIG_HEADER; } @@ -147,6 +165,19 @@ export function legacyPostgresSettingsToPostgresConfig( return `${LEGACY_POSTGRES_CONFIG_HEADER}${toml}`; } +/** + * The `[db.settings]` keys the user actually set — an unset field must never reach either + * renderer below: both the postgresql.conf TOML renderer and the `-c` argv renderer must + * emit only keys the user actually set. + */ +function legacyDefinedPostgresSettings( + settings: CliConfig["db"]["settings"], +): ReadonlyArray { + return Object.entries(settings ?? {}).filter( + (entry): entry is [string, string | number | boolean] => entry[1] !== undefined, + ); +} + /** * Port of Go's `config.VersionCompare` (`apps/cli-go/pkg/config/config.go:885-899`) * — NOT a real semver comparator. A dotted version with more than 3 components @@ -282,7 +313,7 @@ function legacyPostgresExtraEnv( function legacyPostgresEntrypointScriptPg15(postgresConfig: string, args = ""): string { return ( "\n" + - "cat <<'EOF' > /etc/postgresql.schema.sql && \\\n" + + `cat <<'EOF' > ${LEGACY_POSTGRES_SCHEMA_SQL_PATH} && \\\n` + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + `exec docker-entrypoint.sh postgres -D /etc/postgresql ${args}\n` + `${LEGACY_START_DB_SCHEMA_SQL}\n` + @@ -333,7 +364,7 @@ function legacyPostgresEntrypointScriptPg14(postgresConfig: string, args = ""): function legacyPostgresEntrypointScriptRestore(postgresConfig: string): string { return ( "\n" + - "cat <<'EOF' > /etc/postgresql.schema.sql && \\\n" + + `cat <<'EOF' > ${LEGACY_POSTGRES_SCHEMA_SQL_PATH} && \\\n` + "cat <<'EOF' > /docker-entrypoint-initdb.d/migrate.sh && \\\n" + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + "exec docker-entrypoint.sh postgres -D /etc/postgresql\n" + @@ -389,6 +420,18 @@ export function legacyBuildPostgresStartContainerSpec( env, entrypoint: "sh", cmd: ["-c", script], + // The pgsodium root key heredoc/bind is present whenever the ACTUAL entrypoint in use + // embeds it: both `legacyPostgresEntrypointScriptPg15` and + // `legacyPostgresEntrypointScriptRestore` do (Go's `fromBackup` override always re-adds + // its own root-key heredoc, `start.go:147,155`, regardless of major version); only the + // PG<=14 script never references it. + ...(isPg14OrEarlier && !isRestore + ? {} + : { + secretFiles: [ + { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, + ], + }), binds: [ `${containerName}:/var/lib/postgresql/data`, // Go's `StartDatabase` (`start.go:163`) appends this bind ONLY on the `fromBackup` branch — @@ -401,20 +444,9 @@ export function legacyBuildPostgresStartContainerSpec( // check is NOT part of `StartDatabase`'s `fromBackup` override, so this stays keyed on // `isPg14OrEarlier` alone, independent of `isRestore`. ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), - // The pgsodium root key heredoc/bind is present whenever the ACTUAL entrypoint in use embeds - // it: both `legacyPostgresEntrypointScriptPg15` and `legacyPostgresEntrypointScriptRestore` do - // (Go's `fromBackup` override always re-adds its own root-key heredoc, `start.go:147,155`, - // regardless of major version); only the PG<=14 script never references it. - ...(isPg14OrEarlier && !isRestore - ? {} - : { - secretFiles: [ - { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, - ], - }), ports: [{ hostPort: String(input.db.port), containerPort: "5432" }], healthcheck: { - test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], + test: LEGACY_POSTGRES_HEALTHCHECK_TEST, intervalSeconds: LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS, timeoutSeconds: LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS, retries: LEGACY_POSTGRES_HEALTHCHECK_RETRIES, @@ -429,11 +461,18 @@ export function legacyBuildPostgresStartContainerSpec( /** * Go's `NewContainerConfig("-c", "max_worker_processes=0")` (`CreateShadowDatabase`, * `apps/cli-go/internal/db/diff/diff.go:140`) — disables background workers in the - * shadow database. Not a docker flag: it is spliced into the entrypoint script's own - * `docker-entrypoint.sh postgres -D /etc/postgresql ` line, exactly like every - * other `args` value {@link legacyPostgresEntrypointScriptPg15}/`Pg14` accept. + * shadow database. {@link LEGACY_SHADOW_ENTRYPOINT_ARGS} joins it for the + * docker.io entrypoint script's own `` splice point. */ -export const LEGACY_SHADOW_ENTRYPOINT_ARGS = "-c max_worker_processes=0"; +const LEGACY_SHADOW_ENTRYPOINT_ARGV: ReadonlyArray = ["-c", "max_worker_processes=0"]; + +/** + * {@link LEGACY_SHADOW_ENTRYPOINT_ARGV} as the docker.io entrypoint script sees it: + * not a docker flag, but text spliced into the script's own + * `docker-entrypoint.sh postgres -D /etc/postgresql ` line, exactly like + * every other `args` value {@link legacyPostgresEntrypointScriptPg15}/`Pg14` accept. + */ +export const LEGACY_SHADOW_ENTRYPOINT_ARGS = LEGACY_SHADOW_ENTRYPOINT_ARGV.join(" "); /** * Input to {@link legacyBuildShadowPostgresContainerSpec} — the subset of @@ -527,9 +566,6 @@ export function legacyBuildShadowPostgresContainerSpec( env, entrypoint: "sh", cmd: ["-c", script], - binds: [], - autoRemove: true, - ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), ...(isPg14OrEarlier ? {} : { @@ -537,9 +573,12 @@ export function legacyBuildShadowPostgresContainerSpec( { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, ], }), + binds: [], + autoRemove: true, + ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), ports: [{ hostPort: String(input.shadowPort), containerPort: "5432" }], healthcheck: { - test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], + test: LEGACY_POSTGRES_HEALTHCHECK_TEST, intervalSeconds: LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS, timeoutSeconds: LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS, retries: LEGACY_POSTGRES_HEALTHCHECK_RETRIES, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts index a96520a7c7..904df95c28 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts @@ -1,5 +1,5 @@ import type { CliConfig } from "@supabase/config"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { LEGACY_START_DB_RESTORE_SH } from "./templates/db-restore.sh.ts"; import { LEGACY_START_DB_SCHEMA_SQL } from "./templates/db-schema.sql.ts"; @@ -19,6 +19,10 @@ import { const POSTGRES_CONFIG_HEADER = "\n# supabase [db.settings] configuration\n"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + function baseDb(overrides: Partial = {}): CliConfig["db"] { return { port: 54322, @@ -229,6 +233,21 @@ describe("legacyBuildPostgresStartContainerSpec", () => { }); }); + test("healthcheck stays the plain pg_isready probe on docker.io even with the slim flag set", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildPostgresStartContainerSpec(baseInput()); + expect(spec.healthcheck?.test).toEqual([ + "CMD", + "pg_isready", + "-U", + "postgres", + "-h", + "127.0.0.1", + "-p", + "5432", + ]); + }); + test("port binding maps the configured db.port to container port 5432", () => { const spec = legacyBuildPostgresStartContainerSpec(baseInput({ db: baseDb({ port: 12345 }) })); expect(spec.ports).toEqual([{ hostPort: "12345", containerPort: "5432" }]); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts new file mode 100644 index 0000000000..953617173e --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts @@ -0,0 +1,50 @@ +/** + * Slim-image runtime contracts that differ from docker.io. Spec builders switch + * on {@link usesSlimImageRuntime} so flag-off stays byte-identical even if a + * caller passes a ghcr-shaped override. + * + * Docker CLI `--health-cmd` is always stored as `CMD-SHELL` and executed with + * `/bin/sh -c` (`docker-create-args.ts`). Distroless images with no `/bin/sh` + * (auth, studio, pg-meta) therefore cannot carry a Docker healthcheck through + * this CLI — omit it and let `legacyCheckContainerReady` treat `Running` as + * ready, the same as PostgREST. Elixir/busybox images (realtime, analytics) + * and Vector do ship `/bin/sh` plus a wget applet, so they keep an exec-form + * probe that the CLI quotes into CMD-SHELL. Postgres, storage, and + * edge-runtime now match docker.io (root start, `sh`/`wget`), so they share + * the docker.io specs; the flag only rewrites their image names. + */ + +import { usesSlimImageRuntime } from "../../../shared/services/slim-images.ts"; + +/** {@link usesSlimImageRuntime} under the mandatory `legacy` export prefix. */ +export function legacyUsesSlimRuntime(image: string): boolean { + return usesSlimImageRuntime(image); +} + +export const LEGACY_SLIM_BUSYBOX = "/bin/busybox"; + +export function legacySlimBusyboxWgetHealthcheck( + url: string, + opts: { readonly header?: string; readonly startPeriodSeconds?: number } = {}, +): { + readonly test: ReadonlyArray; + readonly intervalSeconds: number; + readonly timeoutSeconds: number; + readonly retries: number; + readonly startPeriodSeconds?: number; +} { + const test = ["CMD", LEGACY_SLIM_BUSYBOX, "wget", "-q", "--spider"]; + if (opts.header !== undefined) { + test.push("--header", opts.header); + } + test.push(url); + return { + test, + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + ...(opts.startPeriodSeconds === undefined + ? {} + : { startPeriodSeconds: opts.startPeriodSeconds }), + }; +} diff --git a/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.unit.test.ts new file mode 100644 index 0000000000..3ba0844a3c --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.unit.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + LEGACY_SLIM_BUSYBOX, + legacySlimBusyboxWgetHealthcheck, + legacyUsesSlimRuntime, +} from "./slim-runtime.ts"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("legacyUsesSlimRuntime", () => { + it("requires the flag and a slim ref", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + expect(legacyUsesSlimRuntime("ghcr.io/supabase/cli/storage:v1.70.3")).toBe(true); + expect(legacyUsesSlimRuntime("supabase/storage-api:v1.70.3")).toBe(false); + }); +}); + +describe("legacySlimBusyboxWgetHealthcheck", () => { + it("invokes busybox wget --spider", () => { + const check = legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:4000/health", { + header: "Host:realtime-dev", + startPeriodSeconds: 10, + }); + expect(check.test).toEqual([ + "CMD", + LEGACY_SLIM_BUSYBOX, + "wget", + "-q", + "--spider", + "--header", + "Host:realtime-dev", + "http://127.0.0.1:4000/health", + ]); + expect(check.startPeriodSeconds).toBe(10); + }); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts index 19bfb04569..7ed9a183dd 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -9,12 +9,13 @@ * * Exact Go call order: pre-create volume-existence probe (+ the `fromBackup`-on-an-existing-volume * guard) -> image resolve + network ensure (Go's `DockerStart` resolves the image, THEN creates - * the network, both strictly ahead of container create — `docker.go:363-386` — so NEITHER one - * ever runs on a request the volume guard above already rejected) -> Postgres container - * create+start -> health wait (swallowed ONLY when `fromBackup` is set — "restoring a large - * backup may take longer than 2 minutes") -> the fresh-volume `SetupLocalDatabase`-equivalent - * pipeline (skipped IN FULL when `fromBackup` is set) -> `initCurrentBranch`, unconditionally (the - * LAST line of `StartDatabase`, reached on every path that doesn't already return/fail above). + * the network, both strictly + * ahead of container create — `docker.go:363-386` — so NEITHER one ever runs on a request the + * volume guard above already rejected) -> Postgres container create+start -> health wait + * (swallowed ONLY when `fromBackup` is set — "restoring a large backup may take longer than 2 + * minutes") -> the fresh-volume `SetupLocalDatabase`-equivalent pipeline (skipped IN FULL when + * `fromBackup` is set) -> `initCurrentBranch`, unconditionally (the LAST line of `StartDatabase`, + * reached on every path that doesn't already return/fail above). * * Deliberately has ZERO knowledge of `--ignore-health-check` — matching Go exactly: that flag is * `internal/start/start.go`'s `Run()`'s own concern, entirely OUTSIDE `StartDatabase` (Go's @@ -160,10 +161,9 @@ export interface LegacyStartDatabaseInput { readonly webhooksEnabled: boolean; readonly setup: LegacyFreshDbSetupInput; /** - * Fired synchronously, exactly once, right after the pre-create volume probe resolves — - * the caller's own equivalent of Go's package-level `utils.NoBackupVolume` global, needed by - * the caller's OWN `legacyRollbackStart` (which this function does NOT call itself — see this - * module's header) even when this function fails partway through, after the probe. + * Caller's `utils.NoBackupVolume` equivalent for `legacyRollbackStart`. Fired once + * after pre-create refuse guards pass. Skipped on those guards so rollback cannot + * treat leftover sibling volumes as this run's fresh data. */ readonly onFreshVolumeResolved: (isFreshVolume: boolean) => void; } @@ -200,13 +200,12 @@ export const legacyStartDatabase = ( // `VolumeInspect` and the guard both run strictly BEFORE `DockerStart`, which is the ONLY // place Go ever creates the network (`docker.go:363-386`). const isFreshVolume = !(yield* legacyVolumeExists(spawner, input.dbContainerId)); - input.onFreshVolumeResolved(isFreshVolume); - const fromBackup = input.postgresSpec.fromBackup; + if (!isFreshVolume && fromBackup !== undefined) { // Go's `StartDatabase` (`start.go:170-172`): a `--from-backup` restore into an // already-provisioned volume is refused outright, BEFORE any container or network is - // created. + // created — and before freshness is published, so rollback cannot prune it. return yield* Effect.fail( new LegacyStartBackupVolumeExistsError({ message: "backup volume already exists", @@ -215,11 +214,8 @@ export const legacyStartDatabase = ( ); } - // Go's `StartDatabase` (`start.go:168-175`) prints this unconditionally to stderr — Go has - // no output-format concept for this seam at all. Matches every other progress line in this - // same pipeline (`db-setup.ts`'s "Initialising schema..."/"Seeding globals...", - // `legacy-migrate-and-seed.ts`'s "Applying migration ..."), which are also unguarded - // (review: PRRT_kwDOErm0O86VmHkn). + // Go prints this before DockerStart (image resolve). Keep that order so a + // flag-off cold/failed pull still follows the established progress line. yield* output.raw( isFreshVolume ? LEGACY_START_STARTING_DATABASE_MESSAGE @@ -229,6 +225,8 @@ export const legacyStartDatabase = ( const resolvedPostgresImage = yield* input.resolvePostgresImage; + input.onFreshVolumeResolved(isFreshVolume); + // Go's `DockerStart` (`docker.go:363-386`): image resolve, THEN network create, both // strictly ahead of container create — hoisted here to run ONCE per `start` run instead of // once per container (Go's own repeated per-container call is a no-op after the first, see diff --git a/apps/cli/src/legacy/shared/legacy-db-image.ts b/apps/cli/src/legacy/shared/legacy-db-image.ts index c7b7ec3690..7cf2999fa9 100644 --- a/apps/cli/src/legacy/shared/legacy-db-image.ts +++ b/apps/cli/src/legacy/shared/legacy-db-image.ts @@ -1,5 +1,6 @@ import { Effect, type FileSystem, type Path } from "effect"; -import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; +import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; +import { slimImageForCurrentPin } from "../../shared/services/slim-images.ts"; /** * Resolves the local Postgres Docker image the way `config.Load` does, @@ -11,7 +12,10 @@ import { dockerfileServiceImage } from "../../shared/services/dockerfile-images. * into `config.Images`, so the TS port tracks Dependabot bumps in that source. */ -const LEGACY_PG_IMAGE = dockerfileServiceImage("pg"); +// Read per call, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is +// observed by the resolver (and by tests that stub the env). +const legacyPgImageRaw = () => dockerfileServiceImageRaw("pg"); +// Major-version fallbacks and the OrioleDB tags below have no slim build. const LEGACY_PG14 = "supabase/postgres:14.1.0.89"; const LEGACY_PG15 = "supabase/postgres:15.8.1.085"; @@ -77,20 +81,9 @@ export const legacyResolveDbImage = Effect.fnUntraced(function* ( ? `supabase/postgres:${orioledbVersion}-orioledb` : `supabase/postgres:orioledb-${orioledbVersion}`; } - let image = LEGACY_PG_IMAGE; - switch (majorVersion) { - case 13: - image = LEGACY_PG15; - break; - case 14: - image = LEGACY_PG14; - break; - case 15: - image = LEGACY_PG15; - break; - default: - break; - } + const usedFallback = majorVersion === 13 || majorVersion === 14 || majorVersion === 15; + let image = usedFallback ? (majorVersion === 14 ? LEGACY_PG14 : LEGACY_PG15) : legacyPgImageRaw(); + let appliedPin: string | undefined; if (majorVersion > 14) { const versionPath = path.join(workdir, "supabase", ".temp", "postgres-version"); const pinned = yield* fs.readFileString(versionPath).pipe( @@ -101,9 +94,14 @@ export const legacyResolveDbImage = Effect.fnUntraced(function* ( const colon = image.indexOf(":"); const currentTag = colon >= 0 ? image.slice(colon + 1) : image; if (versionCompare(currentTag, "15.1.0.55") >= 0) { - image = replaceImageTag(LEGACY_PG_IMAGE, pinned); + image = replaceImageTag(image, pinned); + appliedPin = pinned; } } } - return image; + // 13/14/15 fallbacks have no slim build. Historical PG17 pins stay docker.io. + if (usedFallback) { + return image; + } + return slimImageForCurrentPin("pg", legacyPgImageRaw(), appliedPin); }); diff --git a/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts index f74184da96..5693970cbb 100644 --- a/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts @@ -1,15 +1,29 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Path } from "effect"; +import { afterEach, vi } from "vitest"; -import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; +import { + dockerfileServiceImage, + dockerfileServiceImageRaw, +} from "../../shared/services/dockerfile-images.ts"; +import { toSlimImage } from "../../shared/services/slim-images.ts"; import { legacyResolveDbImage } from "./legacy-db-image.ts"; +const currentPostgres = dockerfileServiceImageRaw("pg"); +const currentPostgresTag = currentPostgres.split(":")[1] ?? ""; + const withTemp = () => mkdtempSync(join(tmpdir(), "legacy-db-image-")); +const writePin = (workdir: string, pinned: string) => { + const dir = join(workdir, "supabase", ".temp"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "postgres-version"), pinned); +}; + const resolve = (workdir: string, majorVersion: number, orioledbVersion?: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -47,4 +61,40 @@ describe("legacyResolveDbImage", () => { rmSync(dir, { recursive: true, force: true }); }); }); + + describe("pinned version with the slim-images flag on", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.effect("keeps a 13/14/15 fallback on docker.io, not the slim registry", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = withTemp(); + writePin(dir, "15.8.1.100"); + return Effect.gen(function* () { + expect(yield* resolve(dir, 15)).toBe("supabase/postgres:15.8.1.100"); + rmSync(dir, { recursive: true, force: true }); + }); + }); + + it.effect("keeps a historical default-major pin on docker.io", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = withTemp(); + writePin(dir, "17.9.9.999"); + return Effect.gen(function* () { + expect(yield* resolve(dir, 17)).toBe("supabase/postgres:17.9.9.999"); + rmSync(dir, { recursive: true, force: true }); + }); + }); + + it.effect("rewrites the current Dockerfile pin to the slim registry", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = withTemp(); + writePin(dir, currentPostgresTag); + return Effect.gen(function* () { + expect(yield* resolve(dir, 17)).toBe(toSlimImage("pg", currentPostgres)); + rmSync(dir, { recursive: true, force: true }); + }); + }); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-docker-registry.ts b/apps/cli/src/legacy/shared/legacy-docker-registry.ts index eda19ae402..04e029ed55 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-registry.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-registry.ts @@ -12,7 +12,16 @@ * When no registry override is configured, callers that can retry pulls should * use `legacyGetRegistryImageUrlCandidates`: ECR stays the fast default, with * GHCR and the source image as fallbacks for transient registry throttling. + * + * Slim images (`isSlimImageRef`) skip every rewrite below and pull from where + * they exist: both helpers key their rewrite on an image's LAST path segment, + * which would turn `ghcr.io/supabase/cli/postgres:…` into the unrelated + * non-slim `…/supabase/postgres:…` mirror. There is no mirror to redirect + * slim refs to, hence `SUPABASE_INTERNAL_IMAGE_REGISTRY` does not apply to + * them either. */ +import { isSlimImageRef } from "../../shared/services/slim-images.ts"; + const LEGACY_INTERNAL_IMAGE_REGISTRY_ENV = "SUPABASE_INTERNAL_IMAGE_REGISTRY"; const DEFAULT_REGISTRY = "public.ecr.aws"; const DEFAULT_SUPABASE_REGISTRY = `${DEFAULT_REGISTRY}/supabase`; @@ -57,6 +66,9 @@ export function legacyGetRegistryImageUrl( imageName: string, projectEnvValues?: Readonly>, ): string { + if (isSlimImageRef(imageName)) { + return imageName; + } const registry = legacyGetRegistry(projectEnvValues); if (registry === DOCKER_HUB_REGISTRY) { return imageName; @@ -68,6 +80,10 @@ export function legacyGetRegistryImageUrlCandidates( imageName: string, projectEnvValues?: Readonly>, ): ReadonlyArray { + if (isSlimImageRef(imageName)) { + return [imageName]; + } + if (legacyGetRegistryOverride(projectEnvValues) !== undefined) { return [legacyGetRegistryImageUrl(imageName, projectEnvValues)]; } diff --git a/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts index b1c81c8ee2..d44acc32e5 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts @@ -122,4 +122,52 @@ describe("legacyGetRegistryImageUrl", () => { ), ).toBe("merged.example/supabase/pg_prove:3.36"); }); + + // Slim images are published only under `ghcr.io/supabase/cli`. Rewriting them + // by last path segment would silently pull the unrelated non-slim mirror, and + // no mirror of them exists for a registry override to point at. + const SLIM_IMAGE = "ghcr.io/supabase/cli/postgres:17.6.1.165"; + + it("leaves a slim image unrewritten, whatever the registry override says", () => { + for (const registry of [undefined, "public.ecr.aws", "docker.io", "my.mirror.example"]) { + expect(withRegistry(registry, () => legacyGetRegistryImageUrl(SLIM_IMAGE))).toBe(SLIM_IMAGE); + } + expect( + withRegistry(undefined, () => + legacyGetRegistryImageUrl(SLIM_IMAGE, { + SUPABASE_INTERNAL_IMAGE_REGISTRY: "my.mirror.example", + }), + ), + ).toBe(SLIM_IMAGE); + }); + + it("plans a single pull candidate for a slim image", () => { + for (const registry of [undefined, "public.ecr.aws", "docker.io", "my.mirror.example"]) { + expect(withRegistry(registry, () => legacyGetRegistryImageUrlCandidates(SLIM_IMAGE))).toEqual( + [SLIM_IMAGE], + ); + } + expect( + withRegistry(undefined, () => + legacyGetRegistryImageUrlCandidates(SLIM_IMAGE, { + SUPABASE_INTERNAL_IMAGE_REGISTRY: "my.mirror.example", + }), + ), + ).toEqual([SLIM_IMAGE]); + }); + + it("still rewrites the non-slim ghcr.io/supabase namespace", () => { + expect( + withRegistry("docker.io", () => legacyGetRegistryImageUrl("ghcr.io/supabase/postgres:17.6")), + ).toBe("ghcr.io/supabase/postgres:17.6"); + expect( + withRegistry(undefined, () => + legacyGetRegistryImageUrlCandidates("ghcr.io/supabase/postgres:17.6"), + ), + ).toEqual([ + "public.ecr.aws/supabase/postgres:17.6", + "ghcr.io/supabase/postgres:17.6", + "supabase/postgres:17.6", + ]); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts index 6523ac2a7d..3841ed4d4c 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts @@ -1,5 +1,10 @@ import { Effect, type FileSystem, type Path } from "effect"; -import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; +import { DENO1_EDGE_RUNTIME_VERSION } from "../../shared/functions/functions.shared.ts"; +import { + dockerfileServiceImage, + dockerfileServiceImageRaw, +} from "../../shared/services/dockerfile-images.ts"; +import { slimImageForCurrentPin } from "../../shared/services/slim-images.ts"; /** * Resolves the edge-runtime Docker image the way Go's `config.Load` does @@ -12,40 +17,52 @@ import { dockerfileServiceImage } from "../../shared/services/dockerfile-images. * image instead (default `deno_version = 2` keeps the Dockerfile image). */ -export const LEGACY_EDGE_RUNTIME_IMAGE = dockerfileServiceImage("edgeruntime"); -// `deno1` (`pkg/config/constants.go:15`) — used when `deno_version = 1`. -const LEGACY_EDGE_RUNTIME_DENO1_IMAGE = "supabase/edge-runtime:v1.68.4"; +// Read per call, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is +// observed by the resolver (and by tests that stub the env). +export const legacyEdgeRuntimeImage = () => dockerfileServiceImage("edgeruntime"); +// `deno1` (`pkg/config/constants.go:15`) — used when `deno_version = 1`. No slim +// build exists for it, so it stays on docker.io regardless of the flag — the +// same exception `edgeRuntimeImage` (`shared/functions/functions.shared.ts`) +// applies for the functions Docker paths reading the SAME pin file. +const LEGACY_EDGE_RUNTIME_DENO1_IMAGE = `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`; -/** `pkg/config/utils.go:81` — replace everything after the first `:` with `tag`. */ -function replaceImageTag(image: string, tag: string): string { - const index = image.indexOf(":"); - return image.slice(0, index + 1) + tag.trim(); -} - -/** - * Resolve the edge-runtime image, honoring the pinned tag in - * `supabase/.temp/edge-runtime-version` and the `deno_version` selector - * (default 2 → Dockerfile image; 1 → `deno1`). The version pin is applied first - * (Go's `Load`), then `deno_version = 1` overrides to `deno1` (Go's validate - * pass). - */ -export const legacyResolveEdgeRuntimeImage = Effect.fnUntraced(function* ( +const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, workdir: string, denoVersion: number, ) { - let image = LEGACY_EDGE_RUNTIME_IMAGE; + if (denoVersion === 1) { + return LEGACY_EDGE_RUNTIME_DENO1_IMAGE; + } + const raw = dockerfileServiceImageRaw("edgeruntime"); const versionPath = path.join(workdir, "supabase", ".temp", "edge-runtime-version"); const pinned = yield* fs.readFileString(versionPath).pipe( Effect.map((s) => s.trim()), Effect.orElseSucceed(() => ""), ); - if (pinned.length > 0) { - image = replaceImageTag(LEGACY_EDGE_RUNTIME_IMAGE, pinned); - } - if (denoVersion === 1) { - image = LEGACY_EDGE_RUNTIME_DENO1_IMAGE; + if (pinned === DENO1_EDGE_RUNTIME_VERSION) { + return LEGACY_EDGE_RUNTIME_DENO1_IMAGE; } - return image; + return slimImageForCurrentPin("edgeruntime", raw, pinned.length > 0 ? pinned : undefined); }); + +/** + * Resolve the edge-runtime image, honoring the pinned tag in + * `supabase/.temp/edge-runtime-version` and the `deno_version` selector + * (default 2 → Dockerfile image; 1 → `deno1`). The version pin is applied first + * (Go's `Load`), then `deno_version = 1` overrides to `deno1` (Go's validate + * pass). Historical pins stay on docker.io — those slim tags are not published. + */ +export const legacyResolveEdgeRuntimeImage = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + denoVersion: number, +) => resolveEdgeRuntimeImage(fs, path, workdir, denoVersion); + +/** + * Same resolution as {@link legacyResolveEdgeRuntimeImage}. The slim image now + * ships `sh`, so shell-entrypoint callers no longer need a docker.io pin. + */ +export const legacyResolveEdgeRuntimeShellImage = legacyResolveEdgeRuntimeImage; diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts index 65a850247b..babd59c64f 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts @@ -2,11 +2,22 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; +import { afterEach, describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Path } from "effect"; +import { vi } from "vitest"; -import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; -import { legacyResolveEdgeRuntimeImage } from "./legacy-edge-runtime-image.ts"; +import { + dockerfileServiceImage, + dockerfileServiceImageRaw, +} from "../../shared/services/dockerfile-images.ts"; +import { toSlimImage } from "../../shared/services/slim-images.ts"; +import { + legacyResolveEdgeRuntimeImage, + legacyResolveEdgeRuntimeShellImage, +} from "./legacy-edge-runtime-image.ts"; + +const currentEdgeRuntime = dockerfileServiceImageRaw("edgeruntime"); +const currentEdgeRuntimeTag = currentEdgeRuntime.split(":")[1] ?? ""; const resolve = (workdir: string, denoVersion: number) => Effect.gen(function* () { @@ -15,6 +26,13 @@ const resolve = (workdir: string, denoVersion: number) => return yield* legacyResolveEdgeRuntimeImage(fs, path, workdir, denoVersion); }).pipe(Effect.provide(BunServices.layer)); +const resolveShell = (workdir: string, denoVersion: number) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyResolveEdgeRuntimeShellImage(fs, path, workdir, denoVersion); + }).pipe(Effect.provide(BunServices.layer)); + describe("legacyResolveEdgeRuntimeImage", () => { it.effect("returns the edge-runtime image from the Dockerfile when nothing is pinned", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); @@ -28,6 +46,18 @@ describe("legacyResolveEdgeRuntimeImage", () => { ); }); + it.effect("resolves the shell-pinned variant to the same image while the flag is off", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); + return resolveShell(dir, 2).pipe( + Effect.tap((image) => + Effect.sync(() => { + expect(image).toBe(dockerfileServiceImage("edgeruntime")); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("honors the pinned tag in .temp/edge-runtime-version", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); @@ -53,4 +83,91 @@ describe("legacyResolveEdgeRuntimeImage", () => { ), ); }); + + describe("with the slim-images flag on", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.effect("keeps a historical pin on docker.io", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); + mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(dir, "supabase", ".temp", "edge-runtime-version"), "v9.9.9\n"); + return resolve(dir, 2).pipe( + Effect.tap((image) => + Effect.sync(() => { + expect(image).toBe("supabase/edge-runtime:v9.9.9"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("rewrites the current Dockerfile pin onto the slim base", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); + mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); + writeFileSync( + join(dir, "supabase", ".temp", "edge-runtime-version"), + `${currentEdgeRuntimeTag}\n`, + ); + return resolve(dir, 2).pipe( + Effect.tap((image) => + Effect.sync(() => { + expect(image).toBe(toSlimImage("edgeruntime", currentEdgeRuntime)); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("keeps a historical shell pin on docker.io", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); + mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(dir, "supabase", ".temp", "edge-runtime-version"), "v9.9.9\n"); + return resolveShell(dir, 2).pipe( + Effect.tap((image) => + Effect.sync(() => { + expect(image).toBe("supabase/edge-runtime:v9.9.9"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("rewrites the shell resolver's current pin onto the slim base", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); + mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); + writeFileSync( + join(dir, "supabase", ".temp", "edge-runtime-version"), + `${currentEdgeRuntimeTag}\n`, + ); + return resolveShell(dir, 2).pipe( + Effect.tap((image) => + Effect.sync(() => { + expect(image).toBe(toSlimImage("edgeruntime", currentEdgeRuntime)); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("keeps a deno1-tag pin on docker.io, where that tag exists", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); + mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(dir, "supabase", ".temp", "edge-runtime-version"), "v1.68.4\n"); + return resolve(dir, 2).pipe( + Effect.tap((image) => + Effect.sync(() => { + expect(image).toBe("supabase/edge-runtime:v1.68.4"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts index 41bdcd6ba4..462e6896d9 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, Exit, Layer, Option } from "effect"; +import { vi } from "vitest"; import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; @@ -192,6 +193,26 @@ describe("legacyEdgeRuntimeScriptLayer sentinel handling", () => { }, ); + it.effect("rewrites the runner onto the slim image with the slim-images flag on", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const { layer, docker } = setup({ + exitCode: 1, + stdout: "", + stderr: "main worker has been destroyed\n", + }); + return runScript().pipe( + Effect.tap(() => + Effect.sync(() => { + expect(docker.lastOpts?.entrypoint).toStrictEqual(Option.some("sh")); + expect(docker.lastOpts?.image).toContain("ghcr.io/supabase/cli/"); + expect(docker.lastOpts?.image).toContain("edge-runtime:"); + }), + ), + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), + ); + }); + it.effect( "disables SELinux label separation so the container can read CLI-written workspace files", () => { diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts index 17e1a65d6f..9146fd38e5 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts @@ -7,7 +7,7 @@ import { LegacyCliSettings } from "../config/legacy-cli-settings.service.ts"; import { legacyReadDbToml } from "./legacy-db-config.toml-read.ts"; import { legacyGetRegistryImageUrl } from "./legacy-docker-registry.ts"; import { LegacyDockerRun } from "./legacy-docker-run.service.ts"; -import { legacyResolveEdgeRuntimeImage } from "./legacy-edge-runtime-image.ts"; +import { legacyResolveEdgeRuntimeShellImage } from "./legacy-edge-runtime-image.ts"; import { LegacyEdgeRuntimeScriptError } from "./legacy-edge-runtime-script.errors.ts"; import { LEGACY_EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL, @@ -101,8 +101,10 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( (error) => new LegacyEdgeRuntimeScriptError({ message: error.message }), ), )).denoVersion; + // Shell-pinned resolution: this runner delivers `index.ts` through an + // `sh -c` here-document, which the distroless slim image cannot run. const registryImage = legacyGetRegistryImageUrl( - yield* legacyResolveEdgeRuntimeImage(fs, path, workdir, denoVersion), + yield* legacyResolveEdgeRuntimeShellImage(fs, path, workdir, denoVersion), ); const port = yield* allocateFreeHostPort; const startCmd = legacyBuildEdgeRuntimeStartCmd({ port, debug }).join(" "); diff --git a/apps/cli/src/legacy/shared/legacy-pg-dump.run.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.run.unit.test.ts new file mode 100644 index 0000000000..4cfd6f2e04 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-pg-dump.run.unit.test.ts @@ -0,0 +1,83 @@ +import { Effect, Layer, Option } from "effect"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { LegacyDockerRun, type LegacyDockerRunOpts } from "./legacy-docker-run.service.ts"; +import { legacyStreamPgDump } from "./legacy-pg-dump.run.ts"; + +const DOCKER_IO_IMAGE = "supabase/postgres:17.4.1.030"; +const SLIM_IMAGE = "ghcr.io/supabase/cli/postgres:17.6.1.165"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function mockDockerRun() { + const calls: LegacyDockerRunOpts[] = []; + const layer = Layer.succeed(LegacyDockerRun, { + run: () => Effect.succeed(0), + runCapture: () => Effect.succeed({ exitCode: 0, stdout: new Uint8Array(0), stderr: "" }), + runStream: (opts) => + Effect.sync(() => { + calls.push(opts); + return { exitCode: 0, stderr: "" }; + }), + }); + return { + layer, + get lastOpts() { + return calls[calls.length - 1]; + }, + }; +} + +const runtimeInfoLayer = Layer.succeed(RuntimeInfo, { + cwd: "/work/project", + platform: "linux", + arch: "x64", + homeDir: "/home/user", + execPath: "/usr/bin/supabase", + pid: 1234, +}); + +function runStreamPgDump(image: string): LegacyDockerRunOpts { + const docker = mockDockerRun(); + const layer = Layer.mergeAll( + docker.layer, + runtimeInfoLayer, + Layer.succeed(LegacyNetworkIdFlag, Option.none()), + ); + Effect.runSync( + legacyStreamPgDump({ + image, + script: "pg_dump", + env: {}, + onStdout: () => Effect.void, + }).pipe(Effect.provide(layer)), + ); + const opts = docker.lastOpts; + if (opts === undefined) throw new Error("docker.runStream was never called"); + return opts; +} + +describe("legacyStreamPgDump entrypoint wiring", () => { + test("docker.io: keeps the image's own entrypoint, running bash under it", () => { + const opts = runStreamPgDump(DOCKER_IO_IMAGE); + expect(opts.entrypoint).toBeUndefined(); + expect(opts.cmd).toEqual(["bash", "-c", "pg_dump", "--"]); + }); + + test("SUPABASE_USE_SLIM_IMAGES unset: a ghcr.io-shaped image still keeps the docker.io cmd shape (flag-off byte-identity)", () => { + const opts = runStreamPgDump(SLIM_IMAGE); + expect(opts.entrypoint).toBeUndefined(); + expect(opts.cmd).toEqual(["bash", "-c", "pg_dump", "--"]); + }); + + test("slim image + flag on: uses the same bash cmd as docker.io", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const opts = runStreamPgDump(SLIM_IMAGE); + expect(opts.entrypoint).toBeUndefined(); + expect(opts.cmd).toEqual(["bash", "-c", "pg_dump", "--"]); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-status-values.ts b/apps/cli/src/legacy/shared/legacy-status-values.ts index 744906721a..c51f2ac46a 100644 --- a/apps/cli/src/legacy/shared/legacy-status-values.ts +++ b/apps/cli/src/legacy/shared/legacy-status-values.ts @@ -1,6 +1,6 @@ import type { CliConfig } from "@supabase/config"; -import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; +import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; import { legacyServiceContainerIds } from "./legacy-docker-ids.ts"; import { legacyEnvOverrideBool, @@ -188,19 +188,22 @@ export function legacyShortContainerImageName(imageName: string): string { // Default image short names `--exclude` also matches against, // one per gated service. Sourced from the same -// embedded Dockerfile manifest Go parses (`dockerfileServiceImage`), so a version bump +// embedded Dockerfile manifest Go parses (`dockerfileServiceImageRaw`), so a version bump // there is picked up automatically. Pinned-version substitution // (`legacy-db-image.ts`'s `replaceImageTag`) only ever rewrites the portion after the // first `:`, which `legacyShortContainerImageName` discards — so these are invariant to // version pinning and no `.temp/-version` file needs to be read here. -const KONG_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("kong")); -const POSTGREST_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("postgrest")); -const STUDIO_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("studio")); -const GOTRUE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("gotrue")); -const MAILPIT_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("mailpit")); -const STORAGE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImage("storage")); +// They read the RAW manifest so `SUPABASE_USE_SLIM_IMAGES` cannot shift them: +// these names are the established `--exclude`/status-key contract (`gotrue`, +// `storage-api`), while slim refs would report `supabase/cli/auth` etc. +const KONG_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImageRaw("kong")); +const POSTGREST_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImageRaw("postgrest")); +const STUDIO_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImageRaw("studio")); +const GOTRUE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImageRaw("gotrue")); +const MAILPIT_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImageRaw("mailpit")); +const STORAGE_IMAGE_NAME = legacyShortContainerImageName(dockerfileServiceImageRaw("storage")); const EDGE_RUNTIME_IMAGE_NAME = legacyShortContainerImageName( - dockerfileServiceImage("edgeruntime"), + dockerfileServiceImageRaw("edgeruntime"), ); export interface LegacyStatusValuesResult { diff --git a/apps/cli/src/legacy/shared/legacy-status-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-status-values.unit.test.ts index d32f6bb80a..99d596b4b8 100644 --- a/apps/cli/src/legacy/shared/legacy-status-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-status-values.unit.test.ts @@ -1,6 +1,6 @@ import { CliConfigSchema, type CliConfig } from "@supabase/config"; import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { legacyShortContainerImageName, @@ -766,6 +766,43 @@ describe("legacyStatusValues", () => { }); }); +// `--exclude` short names are the established contract, so they must stay on the +// docker.io repo names even when the stack itself runs slim `ghcr.io/supabase/cli` +// images. Re-imports the module so the flag is in effect while its +// image-name constants are built. +describe("--exclude image short names under SUPABASE_USE_SLIM_IMAGES", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("keeps matching the docker.io short names", async () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + vi.resetModules(); + const slimModule = await import("./legacy-status-values.ts"); + + for (const [excluded, omitted] of [ + ["gotrue", "ANON_KEY"], + ["storage-api", "STORAGE_S3_URL"], + ["kong", "API_URL"], + ["mailpit", "MAILPIT_URL"], + ["postgrest", "REST_URL"], + ["studio", "STUDIO_URL"], + ["edge-runtime", "FUNCTIONS_URL"], + ] as const) { + const { values } = slimModule.legacyStatusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [excluded], + NO_OVERRIDES, + WORKDIR, + ); + expect(values[omitted], `--exclude ${excluded}`).toBeUndefined(); + } + }); +}); + describe("legacyShortContainerImageName", () => { it("extracts the repo name between the first slash and the last colon", () => { expect(legacyShortContainerImageName("supabase/storage-api:v1.61.9")).toBe("storage-api"); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 1cf21cd163..63252b3c29 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -42,10 +42,10 @@ import { } from "./deploy.errors.ts"; import { buildFunctionsDockerRunArgs, + edgeRuntimeCacheVolume, ensureDockerNamedVolume, ensureDockerNetwork, isDockerRunning, - localDockerId, resolveDockerNetworkMode, resolveEdgeRuntimeVersion, resolveFunctionsDockerImage, @@ -1231,9 +1231,10 @@ export async function buildDockerBinds( }, ]; if (process.env["BITBUCKET_CLONE_DIR"] === undefined) { + const cacheVolume = edgeRuntimeCacheVolume(projectId); binds.unshift({ - hostPath: localDockerId("edge_runtime", projectId), - containerPath: "/root/.cache/deno", + hostPath: cacheVolume.name, + containerPath: cacheVolume.containerPath, mode: "rw", externalScope: false, }); @@ -1423,6 +1424,10 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( }); } const outputPath = join(outputDir, "output.eszip"); + // `edgeRuntimeImage` applies the tag VERBATIM (Go's `replaceImageTag`) + // — a `.temp/edge-runtime-version` pin flows through unmodified, `v` + // prefix or not (see the helper's doc in `functions.shared.ts`). + const rawImage = edgeRuntimeImage(edgeRuntimeVersion); const binds = yield* Effect.promise(() => buildDockerBinds(projectId, functionsDir, outputDir, config, { onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), @@ -1435,15 +1440,9 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( // `PulledEdgeRuntimeImage` is: per-slug matches Go's per-container // `DockerStart` exactly, and the first resolve failure aborts the loop, // so the only cost is one cached `docker image inspect` per function. - const image = yield* resolveFunctionsDockerImage( - // `edgeRuntimeImage` applies the tag VERBATIM (Go's `replaceImageTag`) - // — a `.temp/edge-runtime-version` pin flows through unmodified, `v` - // prefix or not (see the helper's doc in `functions.shared.ts`). - edgeRuntimeImage(edgeRuntimeVersion), - projectEnvValues, - ); + const image = yield* resolveFunctionsDockerImage(rawImage, projectEnvValues); yield* ensureDockerNetwork(networkMode, projectId); - yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId); + yield* ensureDockerNamedVolume(edgeRuntimeCacheVolume(projectId).name, projectId); const env: Array = []; if ( diff --git a/apps/cli/src/shared/functions/deploy.unit.test.ts b/apps/cli/src/shared/functions/deploy.unit.test.ts index 7eb7be3ffb..3f596b108d 100644 --- a/apps/cli/src/shared/functions/deploy.unit.test.ts +++ b/apps/cli/src/shared/functions/deploy.unit.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, realpath, rename, rm, symlink, writeFile } from "node:f import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildDockerBinds, formatDockerBind, type ResolvedDeployFunctionConfig } from "./deploy.ts"; import { FunctionImportNotDirectoryError } from "./deploy.errors.ts"; @@ -645,3 +645,42 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil } }); }); + +describe("buildDockerBinds — edge-runtime Deno-cache volume selection", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("mounts the shared volume at /root/.cache/deno by default", async () => { + const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( + {}, + 'Deno.serve(() => new Response("ok"));\n', + ); + + try { + const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config); + expect(binds.map(formatDockerBind)).toContain( + "supabase_edge_runtime_test-project:/root/.cache/deno:rw", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("mounts the shared /root/.cache/deno volume when the image is slim", async () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( + {}, + 'Deno.serve(() => new Response("ok"));\n', + ); + + try { + const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config); + const formatted = binds.map(formatDockerBind); + expect(formatted).toContain("supabase_edge_runtime_test-project:/root/.cache/deno:rw"); + expect(formatted).not.toContain("supabase_edge_runtime_slim_test-project:/home/nonroot:rw"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index c6f7c1cebe..2b85ba0611 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -18,10 +18,10 @@ import { legacyDescribeContainerCliFailure } from "../../legacy/shared/legacy-co import { legacyViperEnvStringWithProjectFallback } from "../legacy/legacy-viper-env.ts"; import { buildFunctionsDockerRunArgs, + edgeRuntimeCacheVolume, ensureDockerNamedVolume, ensureDockerNetwork, isDockerRunning, - localDockerId, resolveDockerNetworkMode, resolveEdgeRuntimeVersion, resolveFunctionsDockerImage, @@ -48,7 +48,7 @@ const legacyEntrypointPath = "file:///src/index.ts"; // — fixed container-side paths for the docker-unbundle path, unrelated to // deploy's `toDockerPath` host-mirroring scheme. const DOCKER_DENO_DIR = "/home/deno"; -const DOCKER_ESZIP_DIR = "/root/eszips"; +const dockerIoEszipDir = "/root/eszips"; export interface DownloadFunctionsOptions { readonly functionName: Option.Option; @@ -1065,7 +1065,8 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( const { projectId, denoVersion, image, projectEnvValues } = edgeRuntimeImage; const functionsDir = resolve(dependencies.projectRoot, "supabase", "functions"); const hostEszipPath = resolve(eszipPath); - const dockerEszipPath = posix.join(DOCKER_ESZIP_DIR, eszipFileName); + const cacheVolume = edgeRuntimeCacheVolume(projectId); + const dockerEszipPath = posix.join(dockerIoEszipDir, eszipFileName); const dockerOutputPath = posix.join(DOCKER_DENO_DIR, slug); // Go: `viper.GetString("network-id")` else `NetId` (`docker.go:379-383`) — @@ -1091,7 +1092,7 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( yield* ensureDockerNetwork(networkMode, projectId).pipe( Effect.mapError(withLegacyBundleSuggestion(slug, styleAqua)), ); - yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId).pipe( + yield* ensureDockerNamedVolume(cacheVolume.name, projectId).pipe( Effect.mapError(withLegacyBundleSuggestion(slug, styleAqua)), ); @@ -1103,19 +1104,17 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( // environment doesn't allow, same carve-out as `deploy.ts`'s // `buildDockerBinds`. const binds = [ - ...(process.env["BITBUCKET_CLONE_DIR"] === undefined - ? [`${localDockerId("edge_runtime", projectId)}:/root/.cache/deno:rw`] - : []), + ...(process.env["BITBUCKET_CLONE_DIR"] === undefined ? [cacheVolume.bind] : []), `${hostEszipPath}:${dockerEszipPath}:ro`, `${functionsDir}:${DOCKER_DENO_DIR}:rw`, ]; - const command = buildFunctionsDockerRunArgs({ + const spec = { image, projectId, networkMode, binds, containerArgs: ["unbundle", "--eszip", dockerEszipPath, "--output", dockerOutputPath], - }); + }; // Go pipes the container's stdout/stderr straight to `os.Stdout`/`getErrorLogger()` // while the container runs (`DockerRunOnceWithConfig`, copied live via the @@ -1125,41 +1124,50 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( // (`download.go:279`); machine-output modes must keep stdout // payload-only (CLI-1546), so this mirrors `deploy.ts`'s own // `bundleFunctionWithDocker` routing. - const result = yield* runChildProcess("docker", command, { - stdout: "pipe", - stderr: "pipe", - onStdout: (chunk) => output.raw(chunk, output.format === "text" ? "stdout" : "stderr"), - onStderr: (chunk) => output.raw(chunk, "stderr"), - }).pipe( - Effect.mapError( - withDockerStepFailure("failed to run the edge-runtime unbundle container", slug, styleAqua), - ), - ); + const runUnbundleContainer = (command: ReadonlyArray) => + Effect.gen(function* () { + const result = yield* runChildProcess("docker", [...command], { + stdout: "pipe", + stderr: "pipe", + onStdout: (chunk) => output.raw(chunk, output.format === "text" ? "stdout" : "stderr"), + onStderr: (chunk) => output.raw(chunk, "stderr"), + }).pipe( + Effect.mapError( + withDockerStepFailure( + "failed to run the edge-runtime unbundle container", + slug, + styleAqua, + ), + ), + ); - if (result.exitCode !== 0) { - // Go's `getErrorLogger` (deno-v1 only) sets `CmdSuggestion = - // suggestDenoV2()` (assignment) as soon as a full stderr line reads - // "invalid eszip v2" (case-insensitive), then `downloadWithDockerUnbundle` - // appends `suggestLegacyBundle` (`+=`) once extraction has failed - // (`download.go:213,284-304`). Go's own implementation races these two - // goroutines (the pipe writer is never closed) — this resolves that - // race deterministically to the common (non-race) ordering instead of - // reproducing the nondeterminism. The line match is exact (not a - // substring) to match Go's `strings.EqualFold(line, "invalid eszip v2")`. - const invalidEszipV2 = - denoVersion === 1 && - result.stderr - .split(/\r?\n/) - .some((line) => line.trim().toLowerCase() === "invalid eszip v2"); - const suggestion = - (invalidEszipV2 ? suggestDenoV2(styleEmphasis) : "") + suggestLegacyBundle(slug, styleAqua); - return yield* Effect.fail( - Object.assign(new Error(`error running container: exit ${result.exitCode}`), { - suggestion, - }), - ); - } + if (result.exitCode !== 0) { + // Go's `getErrorLogger` (deno-v1 only) sets `CmdSuggestion = + // suggestDenoV2()` (assignment) as soon as a full stderr line reads + // "invalid eszip v2" (case-insensitive), then `downloadWithDockerUnbundle` + // appends `suggestLegacyBundle` (`+=`) once extraction has failed + // (`download.go:213,284-304`). Go's own implementation races these two + // goroutines (the pipe writer is never closed) — this resolves that + // race deterministically to the common (non-race) ordering instead of + // reproducing the nondeterminism. The line match is exact (not a + // substring) to match Go's `strings.EqualFold(line, "invalid eszip v2")`. + const invalidEszipV2 = + denoVersion === 1 && + result.stderr + .split(/\r?\n/) + .some((line) => line.trim().toLowerCase() === "invalid eszip v2"); + const suggestion = + (invalidEszipV2 ? suggestDenoV2(styleEmphasis) : "") + + suggestLegacyBundle(slug, styleAqua); + return yield* Effect.fail( + Object.assign(new Error(`error running container: exit ${result.exitCode}`), { + suggestion, + }), + ); + } + }); + yield* runUnbundleContainer(buildFunctionsDockerRunArgs(spec)); // Go: `downloadWithDockerUnbundle` has no final "Downloaded Function ..." // print, unlike `RunLegacy`/`downloadWithServerSideUnbundle` — its only // stdout/stderr text is "Downloading function: ..." above plus whatever diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 999fc633d1..f8d037a66a 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -9,13 +9,10 @@ import { Effect, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { spawnContainerCli } from "../../legacy/shared/legacy-container-cli.ts"; import { legacyMakeDockerImageResolver } from "../../legacy/shared/legacy-docker-image-resolve.ts"; +import { DENO1_EDGE_RUNTIME_VERSION } from "./functions.shared.ts"; const INVALID_PROJECT_ID = /[^a-zA-Z0-9_.-]+/g; const MAX_PROJECT_ID_LENGTH = 40; -// Go's `deno1` image tag (`pkg/config/constants.go:15`, -// `supabase/edge-runtime:v1.68.4`) — a full tag, since tags flow verbatim -// into `edgeRuntimeImage` (`functions.shared.ts`) with no `v` synthesis. -const DENO1_EDGE_RUNTIME_VERSION = "v1.68.4"; export function toSlash(pathname: string) { return pathname.replaceAll("\\", "/"); @@ -32,6 +29,21 @@ export function localDockerId(name: string, projectId: string) { return `supabase_${name}_${normalizeProjectId(projectId)}`; } +/** + * The Deno-cache volume bind for an edge-runtime container. Both image + * families now run as root, so the shared `supabase_edge_runtime_` + * volume mounts at `/root/.cache/deno`. + */ +export function edgeRuntimeCacheVolume(projectId: string) { + const name = localDockerId("edge_runtime", projectId); + const containerPath = "/root/.cache/deno"; + return { + name, + containerPath, + bind: `${name}:${containerPath}:rw`, + }; +} + /** * Go: `DockerStart`'s network selection (`internal/utils/docker.go:379-383`) * combined with root's `viper.BindPFlags`/`AutomaticEnv` for the persistent @@ -90,7 +102,7 @@ export function toDockerPath(hostPath: string) { * carry its `0644` default. */ export function containerArchiveBytes( - files: Readonly>, + files: Readonly>, ): Promise { return new Bun.Archive( Object.fromEntries( @@ -134,7 +146,14 @@ export interface FunctionsDockerRunSpec { * associate an orphaned container with the project. */ export function buildFunctionsDockerRunArgs(spec: FunctionsDockerRunSpec): Array { - const command = ["run", "--rm", ...spec.binds.flatMap((bind) => ["-v", bind])]; + return buildFunctionsDockerContainerArgs(["run", "--rm"], spec); +} + +function buildFunctionsDockerContainerArgs( + head: ReadonlyArray, + spec: FunctionsDockerRunSpec, +): Array { + const command = [...head, ...spec.binds.flatMap((bind) => ["-v", bind])]; command.push("--network", spec.networkMode); if ((spec.platform ?? process.platform) === "linux") { command.push("--add-host", "host.docker.internal:host-gateway"); diff --git a/apps/cli/src/shared/functions/functions-docker.unit.test.ts b/apps/cli/src/shared/functions/functions-docker.unit.test.ts index 2270a795ad..76ddb2c83d 100644 --- a/apps/cli/src/shared/functions/functions-docker.unit.test.ts +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -7,6 +7,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { buildFunctionsDockerRunArgs, containerArchiveBytes, + edgeRuntimeCacheVolume, localDockerId, resolveDockerNetworkMode, runChildProcess, @@ -211,6 +212,16 @@ describe("buildFunctionsDockerRunArgs", () => { }); }); +describe("edgeRuntimeCacheVolume", () => { + it("keeps the shared volume at /root/.cache/deno", () => { + expect(edgeRuntimeCacheVolume("my-project")).toEqual({ + name: "supabase_edge_runtime_my-project", + containerPath: "/root/.cache/deno", + bind: "supabase_edge_runtime_my-project:/root/.cache/deno:rw", + }); + }); +}); + describe("containerArchiveBytes", () => { // Regular-file tar entries parsed straight from the ustar headers. function tarRegularFileEntries(archive: Uint8Array): ReadonlyArray<[string, number]> { @@ -245,6 +256,13 @@ describe("containerArchiveBytes", () => { const files = await new Bun.Archive(archive).files(); expect(await files.get("root/index.ts")?.text()).toBe("export const x = 1;\n"); }); + + it("accepts binary file bodies", async () => { + const body = new Uint8Array([0x00, 0x01, 0xfe, 0xff]); + const archive = await containerArchiveBytes({ "/tmp/eszips/output.eszip": body }); + const files = await new Bun.Archive(archive).files(); + expect(new Uint8Array(await files.get("tmp/eszips/output.eszip")!.arrayBuffer())).toEqual(body); + }); }); describe("resolveDockerNetworkMode", () => { diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 63f740c849..243158dd36 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -1,7 +1,8 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { Effect } from "effect"; -import { dockerfileServiceImage } from "../services/dockerfile-images.ts"; +import { dockerfileServiceImageRaw } from "../services/dockerfile-images.ts"; +import { imageTag, slimImageForCurrentPin } from "../services/slim-images.ts"; const functionSlugPattern = /^[A-Za-z][A-Za-z0-9_-]*$/; @@ -27,8 +28,11 @@ export const FUNCTIONS_BUNDLER_MUTEX_GROUP = ["use-api", "use-docker", "legacy-b // reads the same source) — sourced from there rather than `@supabase/stack`'s // independently-maintained catalog, so a Dockerfile pin bump can never drift // from what the `functions` Docker paths resolve. -const DEFAULT_EDGE_RUNTIME_IMAGE = dockerfileServiceImage("edgeruntime"); -const DEFAULT_EDGE_RUNTIME_TAG = DEFAULT_EDGE_RUNTIME_IMAGE.split(":")[1] ?? ""; +// Go's `deno1` image tag (`pkg/config/constants.go:15`, +// `supabase/edge-runtime:v1.68.4`) — a full tag, since tags flow verbatim +// into `edgeRuntimeImage` with no `v` synthesis. Shared with +// `functions-docker.ts`'s `resolveEdgeRuntimeVersion`, which selects it. +export const DENO1_EDGE_RUNTIME_VERSION = "v1.68.4"; /** * Go: `replaceImageTag(Images.EdgeRuntime, tag)` (`pkg/config/utils.go:81-84`) @@ -42,10 +46,23 @@ const DEFAULT_EDGE_RUNTIME_TAG = DEFAULT_EDGE_RUNTIME_IMAGE.split(":")[1] ?? ""; * default above and `resolveEdgeRuntimeVersion`'s deno-1 constant. * Single home for the repository too — only the tag half is parameterized, * so a `supabase/edge-runtime` rename in the Dockerfile propagates whole. + * + * `deno_version = 1` is a locked docker.io-only exception (no slim build): + * the "tag" it selects is really a whole different image squeezed through + * this tag-shaped API, so it bypasses the (possibly slim-rewritten) default + * base entirely and returns the full docker.io ref. Flag-off this is + * byte-identical to the general path, since the default base is already + * docker.io then. The tag check deliberately also catches an explicit + * `.temp/edge-runtime-version` pin of this exact tag under the slim flag: + * no slim build of it exists either, so docker.io is the only resolvable + * image for that tag regardless of WHY it was selected — a separate + * deno_version signal would change nothing observable. */ export function edgeRuntimeImage(tag: string): string { - const index = DEFAULT_EDGE_RUNTIME_IMAGE.indexOf(":"); - return DEFAULT_EDGE_RUNTIME_IMAGE.slice(0, index + 1) + tag.trim(); + if (tag === DENO1_EDGE_RUNTIME_VERSION) { + return `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`; + } + return slimImageForCurrentPin("edgeruntime", dockerfileServiceImageRaw("edgeruntime"), tag); } /** @@ -62,6 +79,6 @@ export const resolveEdgeRuntimeVersionPin = Effect.fnUntraced(function* (supabas ).pipe( Effect.map((version) => version.trim()), Effect.catch(() => Effect.succeed("")), - Effect.map((version) => version || DEFAULT_EDGE_RUNTIME_TAG), + Effect.map((version) => version || (imageTag(dockerfileServiceImageRaw("edgeruntime")) ?? "")), ); }); diff --git a/apps/cli/src/shared/functions/functions.shared.unit.test.ts b/apps/cli/src/shared/functions/functions.shared.unit.test.ts new file mode 100644 index 0000000000..72d94bd4ac --- /dev/null +++ b/apps/cli/src/shared/functions/functions.shared.unit.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Effect } from "effect"; + +import { dockerfileServiceImageRaw } from "../services/dockerfile-images.ts"; +import { + DENO1_EDGE_RUNTIME_VERSION, + edgeRuntimeImage, + resolveEdgeRuntimeVersionPin, +} from "./functions.shared.ts"; + +const rawEdgeRuntimeImage = dockerfileServiceImageRaw("edgeruntime"); +const currentEdgeRuntimeTag = rawEdgeRuntimeImage.slice(rawEdgeRuntimeImage.lastIndexOf(":") + 1); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("edgeRuntimeImage", () => { + it("keeps the deno1 tag on the docker.io image even when the slim flag is on", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(edgeRuntimeImage(DENO1_EDGE_RUNTIME_VERSION)).toBe( + `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`, + ); + }); + + it("rewrites the current Dockerfile tag onto the slim ghcr.io image when the flag is on", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(edgeRuntimeImage(currentEdgeRuntimeTag)).toBe( + `ghcr.io/supabase/cli/edge-runtime:${currentEdgeRuntimeTag}`, + ); + }); + + it("keeps a historical pin on docker.io when the flag is on", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(edgeRuntimeImage("v1.73.0")).toBe("supabase/edge-runtime:v1.73.0"); + }); + + it("keeps the deno1 tag on the docker.io image while the flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(edgeRuntimeImage(DENO1_EDGE_RUNTIME_VERSION)).toBe( + `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`, + ); + }); +}); + +describe("resolveEdgeRuntimeVersionPin", () => { + it("falls back to the Dockerfile tag, not the ghcr host, when slim is on and no pin file exists", async () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const tag = await Effect.runPromise(resolveEdgeRuntimeVersionPin("/no-such-supabase-dir")); + expect(tag).toBe(currentEdgeRuntimeTag); + expect(tag.includes("/")).toBe(false); + }); + + it("falls back to the Dockerfile tag while the flag is off", async () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + const tag = await Effect.runPromise(resolveEdgeRuntimeVersionPin("/no-such-supabase-dir")); + expect(tag).toBe(currentEdgeRuntimeTag); + }); +}); diff --git a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts index 6945a2d8f7..d67188e633 100644 --- a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts +++ b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { LEGACY_START_KONG_YML_TEMPLATE } from "../../legacy/commands/start/templates/kong.yml.ts"; -import { LEGACY_EDGE_RUNTIME_IMAGE } from "../../legacy/shared/legacy-edge-runtime-image.ts"; +import { legacyEdgeRuntimeImage } from "../../legacy/shared/legacy-edge-runtime-image.ts"; import { ensureImage, resolveDeadline } from "../../../tests/helpers/docker-image.ts"; import { dockerfileServiceImage } from "../services/dockerfile-images.ts"; import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; @@ -145,7 +145,7 @@ describe("functions serve runtime template (offline)", () => { "boots under edge-runtime with networking disabled and fetches nothing remote", { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, async () => { - const runtimeImage = await ensureImage(LEGACY_EDGE_RUNTIME_IMAGE); + const runtimeImage = await ensureImage(legacyEdgeRuntimeImage()); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-offline-e2e-")); const container = `supabase-serve-offline-e2e-${process.pid.toString()}`; try { @@ -209,7 +209,7 @@ describe("functions serve runtime template (offline)", () => { "returns canonical JWT auth failures", { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, async () => { - const runtimeImage = await ensureImage(LEGACY_EDGE_RUNTIME_IMAGE); + const runtimeImage = await ensureImage(legacyEdgeRuntimeImage()); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-auth-e2e-")); const container = `supabase-serve-auth-e2e-${process.pid.toString()}`; try { @@ -293,7 +293,7 @@ describe("functions serve runtime template (offline)", () => { async () => { const imageDeadline = resolveDeadline(); const [runtimeImage, kongImage] = await Promise.all([ - ensureImage(LEGACY_EDGE_RUNTIME_IMAGE, imageDeadline), + ensureImage(legacyEdgeRuntimeImage(), imageDeadline), ensureImage(dockerfileServiceImage("kong"), imageDeadline), ]); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-kong-e2e-")); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index a763e2ae78..f5252fd7a6 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -75,6 +75,7 @@ import { import { containerArchiveBytes, dockerProjectLabels, + edgeRuntimeCacheVolume, ensureDockerNamedVolume, ensureDockerNetwork, localDockerId, @@ -119,7 +120,7 @@ const ignoredDirNames = new Set([ const dockerLogRetryDelay = Duration.millis(400); const dockerLogDiagnosticTailLength = 4_096; const defaultSupabaseEnv = "development"; -const serveMainContainerPath = "/root/index.ts"; +const serveMainDir = "/root"; const shellVariableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/; let cachedLegacyFunctionsServeMainTemplate: string | undefined; const watchIgnoreGlobs = [ @@ -1664,7 +1665,6 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo const watchableBinds = new Map(); const emittedScopeWarnings = new Set(); const functionsConfig: Record = {}; - for (const config of functionConfigs) { if (!config.enabled) { yield* output.raw(`Skipped serving Function: ${config.slug}\n`, "stderr"); @@ -1717,7 +1717,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo const binds = [...functionBinds.values()]; - yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId); + yield* ensureDockerNamedVolume(edgeRuntimeCacheVolume(projectId).name, projectId); yield* ensureDockerNetwork(networkMode, projectId); const env = [ @@ -1770,10 +1770,11 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo }); const labels = dockerProjectLabels(projectId); + const serveMainFile = `${serveMainDir}/index.ts`; const runtimeCommand = [ "edge-runtime", "start", - "--main-service=/root", + `--main-service=${serveMainDir}`, `--port=${dockerRuntimeServerPort}`, `--policy=${input.config.edgeRuntimePolicy}`, ...buildFunctionsServeInspectArgs(input.inspectMode, input.inspectMain), @@ -1784,7 +1785,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo // `sh -c` argv hits Windows ENAMETOOLONG (#5711), and a single-file host bind mounts as // an empty directory on daemons that cannot see this host's filesystem (#6254, #4190). const serveMainArchive = yield* Effect.tryPromise({ - try: () => containerArchiveBytes({ [serveMainContainerPath]: serveMainTemplate }), + try: () => containerArchiveBytes({ [serveMainFile]: serveMainTemplate }), catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), }); const containerProjectRoot = toDockerPath(input.projectRoot); diff --git a/apps/cli/src/shared/services/dockerfile-images.ts b/apps/cli/src/shared/services/dockerfile-images.ts index d9982ddf9f..2b9bbd032c 100644 --- a/apps/cli/src/shared/services/dockerfile-images.ts +++ b/apps/cli/src/shared/services/dockerfile-images.ts @@ -1,4 +1,5 @@ import serviceImagesDockerfile from "../../../../cli-go/pkg/config/templates/Dockerfile" with { type: "text" }; +import { slimImageForAlias } from "./slim-images.ts"; export interface DockerfileImageSpec { readonly alias: string; @@ -30,7 +31,8 @@ export function parseDockerfileServiceImages( export const dockerfileServiceImages = parseDockerfileServiceImages(serviceImagesDockerfile); -export function dockerfileServiceImage(alias: string): string { +/** The docker.io reference exactly as pinned in the Dockerfile manifest. */ +export function dockerfileServiceImageRaw(alias: string): string { const service = dockerfileServiceImages.find((image) => image.alias === alias); if (service === undefined) { throw new Error(`Missing service image alias '${alias}' in Dockerfile manifest.`); @@ -38,3 +40,13 @@ export function dockerfileServiceImage(alias: string): string { return service.image; } + +/** + * The default image for `alias`, rewritten to its slim `ghcr.io/supabase/cli` + * equivalent when `SUPABASE_USE_SLIM_IMAGES` is set. This is the single choke + * point for default service images; use `dockerfileServiceImageRaw` where the + * docker.io identity itself is the contract (user-facing short names). + */ +export function dockerfileServiceImage(alias: string): string { + return slimImageForAlias(alias, dockerfileServiceImageRaw(alias)); +} diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 56e5aa666d..85547c6b55 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -14,6 +14,7 @@ import { parseDockerfileServiceImages, type DockerfileImageSpec, } from "./dockerfile-images.ts"; +import { slimImageForAlias, slimImageForCurrentPin, slimImagesEnabled } from "./slim-images.ts"; export { parseDockerfileServiceImages } from "./dockerfile-images.ts"; @@ -38,6 +39,12 @@ export interface LocalServiceImageOptions { readonly imageOverrides?: LocalServiceImageOverrides; readonly normalizeVersionTags?: boolean; readonly serviceVersions?: LocalServiceVersionOverrides; + /** + * Legacy `.temp` pins only slim-translate when they match the current + * Dockerfile tag (unpublished historical slim tags). Next start runs + * catalog versions from GHCR, so it leaves this off. + */ + readonly slimCurrentPinOnly?: boolean; } // Mirrors Go's `utils.ProjectRefPattern` (`apps/cli-go/internal/utils/misc.go`). @@ -47,6 +54,7 @@ export interface LocalServiceImageOptions { const PROJECT_REF_PATTERN = /^[a-z]{20}$/; interface ServiceImageSpec { + readonly alias: string; readonly image: string; readonly remoteService: RemoteServiceName | undefined; readonly localService: LocalServiceVersionName; @@ -91,6 +99,7 @@ function localServiceImagesFromSpecs( } return { + alias: service.alias, image, remoteService: service.remoteService, localService: service.localService, @@ -120,7 +129,7 @@ export function postgresImageForDbMajorVersion(majorVersion: number): string | u } } -export function replaceImageTag(image: string, tag: string): string { +function replaceImageTag(image: string, tag: string): string { const index = image.lastIndexOf(":"); if (index === -1) { return image; @@ -141,12 +150,33 @@ function localServiceImagesForOptions( options: LocalServiceImageOptions = {}, ): ReadonlyArray { const normalizeVersionTags = options.normalizeVersionTags ?? true; + const slim = slimImagesEnabled(); return LOCAL_SERVICE_IMAGES.map((service) => { - const baseImage = options.imageOverrides?.[service.localService] ?? service.image; + // An explicit `imageOverrides` entry is a caller-chosen ref (the Postgres + // major-version fallback, a configured edge-runtime image) with no slim + // counterpart, so it keeps the docker.io path even with the flag on. + const override = options.imageOverrides?.[service.localService]; + const baseImage = override ?? slimImageForAlias(service.alias, service.image); const version = options.serviceVersions?.[service.localService]; if (version === undefined || version.trim().length === 0) { return baseImage === service.image ? service : { ...service, image: baseImage }; } + if (override === undefined && slim) { + return { + ...service, + image: options.slimCurrentPinOnly + ? slimImageForCurrentPin(service.alias, service.image, version) + : slimImageForAlias( + service.alias, + replaceImageTag( + service.image, + normalizeVersionTags + ? tagForServiceVersion(service.localService, version) + : version, + ), + ), + }; + } return { ...service, image: replaceImageTag( diff --git a/apps/cli/src/shared/services/services.shared.unit.test.ts b/apps/cli/src/shared/services/services.shared.unit.test.ts index bb343ee103..14c43d8ead 100644 --- a/apps/cli/src/shared/services/services.shared.unit.test.ts +++ b/apps/cli/src/shared/services/services.shared.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { Effect, Redacted } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import serviceImagesDockerfile from "../../../../cli-go/pkg/config/templates/Dockerfile" with { type: "text" }; @@ -20,6 +20,10 @@ const runLinkedFetch = (input: Parameters[0]) Effect.runPromise(fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer))); describe("services shared", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + test("parses service images from Dockerfile FROM aliases", () => { expect( parseDockerfileServiceImages(` @@ -68,6 +72,67 @@ describe("services shared", () => { ]); }); + test("lists slim images when SUPABASE_USE_SLIM_IMAGES is set", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(listLocalServiceVersions().map((row) => row.name)).toEqual([ + "ghcr.io/supabase/cli/postgres", + "ghcr.io/supabase/cli/auth", + "ghcr.io/supabase/cli/postgrest", + "ghcr.io/supabase/cli/realtime", + "ghcr.io/supabase/cli/storage", + "ghcr.io/supabase/cli/edge-runtime", + "ghcr.io/supabase/cli/studio", + "ghcr.io/supabase/cli/pgmeta", + "ghcr.io/supabase/cli/analytics", + "ghcr.io/supabase/cli/pooler", + ]); + }); + + test("keeps historical pins on docker.io when slimCurrentPinOnly is set", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect( + listLocalServiceVersions({ + slimCurrentPinOnly: true, + serviceVersions: { pooler: "2.0.0", analytics: "1.4.0" }, + }), + ).toEqual( + expect.arrayContaining([ + { name: "supabase/supavisor", local: "2.0.0", remote: "" }, + { name: "supabase/logflare", local: "1.4.0", remote: "" }, + ]), + ); + }); + + test("slim-translates catalog version overrides that are not the Dockerfile pin", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(listLocalServiceVersions({ serviceVersions: { storage: "v1.70.3" } })).toContainEqual({ + name: "ghcr.io/supabase/cli/storage", + local: "v1.70.3", + remote: "", + }); + }); + + // The Postgres major-version fallback and a configured edge-runtime image are + // caller-chosen refs with no slim counterpart, so the flag must not touch them. + test("leaves explicit image overrides on docker.io when SUPABASE_USE_SLIM_IMAGES is set", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const rows = listLocalServiceVersions({ + imageOverrides: { + postgres: "supabase/postgres:15.8.1.085", + "edge-runtime": "supabase/edge-runtime:v1.68.4", + }, + normalizeVersionTags: false, + serviceVersions: { postgres: "15.8.1.090" }, + }); + + expect(rows).toEqual( + expect.arrayContaining([ + { name: "supabase/postgres", local: "15.8.1.090", remote: "" }, + { name: "supabase/edge-runtime", local: "v1.68.4", remote: "" }, + ]), + ); + }); + test("can preserve raw local service version overrides", () => { expect( listLocalServiceVersions({ diff --git a/apps/cli/src/shared/services/slim-images.ts b/apps/cli/src/shared/services/slim-images.ts new file mode 100644 index 0000000000..ae37549c15 --- /dev/null +++ b/apps/cli/src/shared/services/slim-images.ts @@ -0,0 +1,131 @@ +import { dockerImageForService, type ServiceName } from "@supabase/stack/versions"; + +const SLIM_IMAGES_ENV = "SUPABASE_USE_SLIM_IMAGES"; +const SLIM_IMAGE_PREFIX = "ghcr.io/supabase/cli/"; + +/** + * Maps embedded-Dockerfile aliases onto the slim service catalog. Aliases with + * no slim build (kong, the `differ`/`migra`/`pgprove` job images) are absent and + * keep their docker.io reference. + */ +const SLIM_SERVICE_BY_ALIAS: Readonly> = { + pg: "postgres", + gotrue: "auth", + postgrest: "postgrest", + realtime: "realtime", + storage: "storage", + edgeruntime: "edge-runtime", + studio: "studio", + pgmeta: "pgmeta", + logflare: "analytics", + supavisor: "pooler", + vector: "vector", + imgproxy: "imgproxy", + mailpit: "mailpit", +}; + +/** + * Ambient process env only — the project-dotenv installers + * (`legacy-db-config.toml-read.ts`, `legacy-local-project-context.ts`) copy + * only a fixed set of keys into `process.env`, not arbitrary flags, so a + * value set only in `supabase/.env` is not observed here. Read per call + * rather than cached so tests can stub the ambient env per case. + */ +export function slimImagesEnabled(): boolean { + const value = process.env[SLIM_IMAGES_ENV]; + return value === "true" || value === "1"; +} + +/** + * Rewrites a docker.io image reference to its `ghcr.io/supabase/cli` slim + * equivalent, keeping the Dockerfile's pinned version. The catalog owns tag + * normalization (`v`-prefixing, `tagPrefix`), so pins that differ only in + * prefix between the two registries (`supavisor`, `logflare`) land on the right + * slim tag. Vector's docker.io tags carry an `-alpine` variant suffix that the + * slim build does not publish, so the strip is scoped to `vector` only — an + * `-alpine`-suffixed pin on any other service is a real tag, not a variant marker. + */ +export function toSlimImage(alias: string, image: string): string { + const service = SLIM_SERVICE_BY_ALIAS[alias]; + if (service === undefined) { + return image; + } + + const tagSeparator = image.lastIndexOf(":"); + if (tagSeparator === -1) { + return image; + } + + const rawTag = image.slice(tagSeparator + 1); + const tag = alias === "vector" ? rawTag.replace(/-alpine$/, "") : rawTag; + return dockerImageForService(service, tag); +} + +/** `toSlimImage` behind the feature flag; a no-op while the flag is off. */ +export function slimImageForAlias(alias: string, image: string): string { + return slimImagesEnabled() ? toSlimImage(alias, image) : image; +} + +export function imageTag(image: string): string | undefined { + const tagSeparator = image.lastIndexOf(":"); + return tagSeparator === -1 ? undefined : image.slice(tagSeparator + 1); +} + +function replaceImageTag(image: string, tag: string): string { + const tagSeparator = image.lastIndexOf(":"); + return tagSeparator === -1 ? image : `${image.slice(0, tagSeparator + 1)}${tag}`; +} + +/** + * True when `pin` catalog-normalizes to the same slim tag as `currentRawImage`. + * Historical `.temp` pins that would become unpublished slim tags return false. + */ +export function pinMatchesCurrentImage( + alias: string, + pin: string, + currentRawImage: string, +): boolean { + const currentTag = imageTag(currentRawImage); + if (currentTag === undefined) { + return false; + } + const service = SLIM_SERVICE_BY_ALIAS[alias]; + if (service === undefined) { + return pin.trim() === currentTag; + } + return dockerImageForService(service, pin) === dockerImageForService(service, currentTag); +} + +/** + * Apply an optional `.temp` pin to the docker.io Dockerfile ref, then + * slim-translate only when the flag is on and the pin is absent or current. + */ +export function slimImageForCurrentPin( + alias: string, + currentRawImage: string, + pin?: string, +): string { + const trimmed = pin?.trim() ?? ""; + const tagged = trimmed.length > 0 ? replaceImageTag(currentRawImage, trimmed) : currentRawImage; + if (!slimImagesEnabled()) { + return tagged; + } + if (trimmed.length > 0 && !pinMatchesCurrentImage(alias, trimmed, currentRawImage)) { + return tagged; + } + return toSlimImage(alias, tagged); +} + +/** Slim images are published only under this prefix; single home for the check. */ +export function isSlimImageRef(image: string): boolean { + return image.startsWith(SLIM_IMAGE_PREFIX); +} + +/** + * True when the flag is on AND `image` is a slim ghcr ref. Spec builders and + * one-shot jobs use this so a ghcr-shaped override with the flag off stays on + * the docker.io contract. + */ +export function usesSlimImageRuntime(image: string): boolean { + return slimImagesEnabled() && isSlimImageRef(image); +} diff --git a/apps/cli/src/shared/services/slim-images.unit.test.ts b/apps/cli/src/shared/services/slim-images.unit.test.ts new file mode 100644 index 0000000000..a8b868bc34 --- /dev/null +++ b/apps/cli/src/shared/services/slim-images.unit.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { dockerfileServiceImageRaw, dockerfileServiceImages } from "./dockerfile-images.ts"; +import { + pinMatchesCurrentImage, + slimImageForAlias, + slimImageForCurrentPin, + slimImagesEnabled, + toSlimImage, + usesSlimImageRuntime, +} from "./slim-images.ts"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +const imageForAlias = (alias: string): string => { + const spec = dockerfileServiceImages.find((image) => image.alias === alias); + if (spec === undefined) { + throw new Error(`Missing service image alias '${alias}' in Dockerfile manifest.`); + } + return spec.image; +}; + +describe("toSlimImage", () => { + it.each([ + ["pg", "ghcr.io/supabase/cli/postgres"], + ["gotrue", "ghcr.io/supabase/cli/auth"], + ["postgrest", "ghcr.io/supabase/cli/postgrest"], + ["realtime", "ghcr.io/supabase/cli/realtime"], + ["storage", "ghcr.io/supabase/cli/storage"], + ["edgeruntime", "ghcr.io/supabase/cli/edge-runtime"], + ["studio", "ghcr.io/supabase/cli/studio"], + ["pgmeta", "ghcr.io/supabase/cli/pgmeta"], + ["logflare", "ghcr.io/supabase/cli/analytics"], + ["supavisor", "ghcr.io/supabase/cli/pooler"], + ["vector", "ghcr.io/supabase/cli/vector"], + ["imgproxy", "ghcr.io/supabase/cli/imgproxy"], + ["mailpit", "ghcr.io/supabase/cli/mailpit"], + ])("maps the %s manifest pin onto %s", (alias, repository) => { + const translated = toSlimImage(alias, imageForAlias(alias)); + expect(translated.slice(0, translated.lastIndexOf(":"))).toBe(repository); + }); + + it("keeps the Dockerfile version pin instead of the catalog default", () => { + expect(toSlimImage("pg", "supabase/postgres:17.6.1.165")).toBe( + "ghcr.io/supabase/cli/postgres:17.6.1.165", + ); + expect(toSlimImage("studio", "supabase/studio:2026.08.17-sha-0c1da8f")).toBe( + "ghcr.io/supabase/cli/studio:2026.08.17-sha-0c1da8f", + ); + }); + + it("v-prefixes pins whose slim tag scheme differs from docker.io's", () => { + expect(toSlimImage("supavisor", "supabase/supavisor:2.9.10")).toBe( + "ghcr.io/supabase/cli/pooler:v2.9.10", + ); + expect(toSlimImage("logflare", "supabase/logflare:1.50.4")).toBe( + "ghcr.io/supabase/cli/analytics:v1.50.4", + ); + expect(toSlimImage("pgmeta", "supabase/postgres-meta:v0.98.0")).toBe( + "ghcr.io/supabase/cli/pgmeta:v0.98.0", + ); + }); + + it("strips vector's docker.io -alpine variant suffix", () => { + expect(toSlimImage("vector", "timberio/vector:0.53.0-alpine")).toBe( + "ghcr.io/supabase/cli/vector:0.53.0", + ); + }); + + it("does not strip -alpine from a non-vector service's tag", () => { + expect(toSlimImage("studio", "supabase/studio:2026.08.17-alpine")).toBe( + "ghcr.io/supabase/cli/studio:2026.08.17-alpine", + ); + }); + + it("passes through aliases with no slim build", () => { + for (const alias of ["kong", "differ", "migra", "pgprove"]) { + const image = imageForAlias(alias); + expect(toSlimImage(alias, image)).toBe(image); + } + }); + + it("passes through an untagged reference", () => { + expect(toSlimImage("pg", "supabase/postgres")).toBe("supabase/postgres"); + }); +}); + +describe("slimImagesEnabled", () => { + it.each([ + ["true", true], + ["1", true], + ["false", false], + ["0", false], + ["yes", false], + ["TRUE", false], + ["", false], + ])("reads %j as %s", (value, expected) => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", value); + expect(slimImagesEnabled()).toBe(expected); + }); + + it("is off when unset", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(slimImagesEnabled()).toBe(false); + }); +}); + +describe("slimImageForAlias", () => { + it("is a no-op while the flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(slimImageForAlias("pg", "supabase/postgres:17.6.1.165")).toBe( + "supabase/postgres:17.6.1.165", + ); + }); + + it("translates when the flag is on", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(slimImageForAlias("pg", "supabase/postgres:17.6.1.165")).toBe( + "ghcr.io/supabase/cli/postgres:17.6.1.165", + ); + }); +}); + +describe("usesSlimImageRuntime", () => { + it("is false while the flag is off even for a ghcr ref", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + expect(usesSlimImageRuntime("ghcr.io/supabase/cli/postgres:17.6.1.165")).toBe(false); + }); + + it("is true only when the flag is on and the ref is slim", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + expect(usesSlimImageRuntime("ghcr.io/supabase/cli/auth:v2.196.0")).toBe(true); + expect(usesSlimImageRuntime("supabase/gotrue:v2.196.0")).toBe(false); + }); +}); + +describe("pinMatchesCurrentImage", () => { + it("treats catalog-equivalent pooler tags as current", () => { + const current = dockerfileServiceImageRaw("supavisor"); + const currentTag = current.split(":")[1] ?? ""; + const altTag = currentTag.startsWith("v") ? currentTag.slice(1) : `v${currentTag}`; + expect(pinMatchesCurrentImage("supavisor", currentTag, current)).toBe(true); + expect(pinMatchesCurrentImage("supavisor", altTag, current)).toBe(true); + expect(pinMatchesCurrentImage("supavisor", "2.0.0", current)).toBe(false); + }); +}); + +describe("slimImageForCurrentPin", () => { + it("slim-translates the current pin and leaves a historical pin on docker.io", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const current = dockerfileServiceImageRaw("storage"); + const currentTag = current.split(":")[1] ?? ""; + expect(slimImageForCurrentPin("storage", current)).toBe(toSlimImage("storage", current)); + expect(slimImageForCurrentPin("storage", current, currentTag)).toBe( + toSlimImage("storage", current), + ); + expect(slimImageForCurrentPin("storage", current, "v1.67.0")).toBe( + "supabase/storage-api:v1.67.0", + ); + }); + + it("is a no-op while the flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + const current = dockerfileServiceImageRaw("storage"); + expect(slimImageForCurrentPin("storage", current, "v1.67.0")).toBe( + "supabase/storage-api:v1.67.0", + ); + }); +}); diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 36b84cee04..96d472377b 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -916,6 +916,10 @@ const LEGACY_SHADOW_STARTING_STATE = * exclusive with `dbInspectFailsWith`, which instead reports a daemon-unreachable failure * (`legacyIsDockerDaemonUnreachable`) with the given stderr text — enforced below (a test * that sets both throws immediately, rather than one option silently winning). + * + * `dbInspectImage` makes the same `supabase_db_`-prefixed inspect report a `Config.Image` + * value instead — for `ensureLocalPostgresImageCurrent`'s stale-image guard, which reads + * that field from the same call `legacyIsLocalDbRunning` only checks the exit code of. */ export function mockLegacyShadowContainerCliSpawner( opts: { @@ -924,6 +928,7 @@ export function mockLegacyShadowContainerCliSpawner( readonly failRemove?: boolean; readonly dbNotRunning?: boolean; readonly dbInspectFailsWith?: string; + readonly dbInspectImage?: string; } = {}, ): { readonly layer: Layer.Layer; @@ -982,6 +987,22 @@ export function mockLegacyShadowContainerCliSpawner( getOutputFd: () => Stream.empty, }); } + if (isLocalDbInspect && opts.dbInspectImage !== undefined) { + const inspectJson = JSON.stringify([{ Config: { Image: opts.dbInspectImage } }]); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(7000 + spawned.length), + stdout: Stream.fromIterable([encoder.encode(inspectJson)]), + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + } let stdoutLines: ReadonlyArray = []; let stderrLines: ReadonlyArray = []; let exitCode = 0; diff --git a/packages/stack/package.json b/packages/stack/package.json index ef7152183c..4ae3d710a8 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -17,6 +17,7 @@ "default": "./src/managed-node.ts" }, "./managed-model": "./src/managed/model.ts", + "./versions": "./src/versions.ts", "./testing": "./src/testing.ts", "./daemon-bun": "./src/daemon-bun.ts" }, From 17f9ef48fc35a2c08cc0ee26beae429271dbf268 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 28 Aug 2026 18:50:54 +0200 Subject: [PATCH 2/7] chore(cli): drop stale slim-image contract comments Published slim images now match docker.io for the services those comments described, so they no longer claim missing migrate-call.js, a distroless-only shell pin, or a slim-baked transform default. Co-authored-by: Cursor --- .../legacy/commands/start/services/storage.service.ts | 4 +--- apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts | 11 +++++------ .../legacy/shared/legacy-edge-runtime-script.layer.ts | 4 ++-- apps/cli/src/shared/functions/deploy.unit.test.ts | 2 +- 4 files changed, 9 insertions(+), 12 deletions(-) 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 55d283180f..87f82eb7de 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.ts @@ -150,9 +150,7 @@ export function legacyBuildStorageEnv(input: LegacyStorageEnvInput): Record 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. + // Slim edge-runtime now ships `sh`, so the heredoc entrypoint + // works on both image families. const registryImage = legacyGetRegistryImageUrl( yield* legacyResolveEdgeRuntimeShellImage(fs, path, workdir, denoVersion), ); diff --git a/apps/cli/src/shared/functions/deploy.unit.test.ts b/apps/cli/src/shared/functions/deploy.unit.test.ts index 3f596b108d..a1c5486065 100644 --- a/apps/cli/src/shared/functions/deploy.unit.test.ts +++ b/apps/cli/src/shared/functions/deploy.unit.test.ts @@ -667,7 +667,7 @@ describe("buildDockerBinds — edge-runtime Deno-cache volume selection", () => } }); - it("mounts the shared /root/.cache/deno volume when the image is slim", async () => { + it("uses the shared /root/.cache/deno volume under the slim flag", async () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( {}, From 714e07383163fc9f89f7eca8652afb4d337f69bb Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 28 Aug 2026 19:01:57 +0200 Subject: [PATCH 3/7] fix(cli): rewrite slim pooler and vector onto ghcr.io/supabase/cli The stack catalog maps those services to ghcr.io/supabase/{supavisor,vector}, so the flag pulled the wrong images and skipped the slim runtime forks. Keep catalog tag normalization, then always emit ghcr.io/supabase/cli/. Co-authored-by: Cursor --- apps/cli/src/shared/services/slim-images.ts | 23 +++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/shared/services/slim-images.ts b/apps/cli/src/shared/services/slim-images.ts index ae37549c15..d75bc30784 100644 --- a/apps/cli/src/shared/services/slim-images.ts +++ b/apps/cli/src/shared/services/slim-images.ts @@ -36,6 +36,25 @@ export function slimImagesEnabled(): boolean { return value === "true" || value === "1"; } +/** + * Catalog-normalized slim tag under `ghcr.io/supabase/cli/`. + * `dockerImageForService` still owns v-prefix / tagPrefix rules, but vector + * and pooler override that helper onto `ghcr.io/supabase/{vector,supavisor}` + * — not the slim CLI repos. Pooler's slim tags are published with a `v`. + */ +function slimTagForService(service: ServiceName, rawTag: string): string { + const catalogRef = dockerImageForService(service, rawTag); + const catalogTag = imageTag(catalogRef) ?? rawTag; + if (service === "pooler" && !catalogTag.startsWith("v")) { + return `v${catalogTag}`; + } + return catalogTag; +} + +function slimImageRef(service: ServiceName, rawTag: string): string { + return `${SLIM_IMAGE_PREFIX}${service}:${slimTagForService(service, rawTag)}`; +} + /** * Rewrites a docker.io image reference to its `ghcr.io/supabase/cli` slim * equivalent, keeping the Dockerfile's pinned version. The catalog owns tag @@ -58,7 +77,7 @@ export function toSlimImage(alias: string, image: string): string { const rawTag = image.slice(tagSeparator + 1); const tag = alias === "vector" ? rawTag.replace(/-alpine$/, "") : rawTag; - return dockerImageForService(service, tag); + return slimImageRef(service, tag); } /** `toSlimImage` behind the feature flag; a no-op while the flag is off. */ @@ -93,7 +112,7 @@ export function pinMatchesCurrentImage( if (service === undefined) { return pin.trim() === currentTag; } - return dockerImageForService(service, pin) === dockerImageForService(service, currentTag); + return slimTagForService(service, pin) === slimTagForService(service, currentTag); } /** From 9c8a4e1defdc5ac32407e1b932bf03ae744467d5 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 28 Aug 2026 19:07:52 +0200 Subject: [PATCH 4/7] test(cli): derive seam image-current fixtures from the Dockerfile pin The flag-off match case hardcoded postgres 17.6.1.165 after the pin moved to 17.6.1.166, so the test was committed red. Co-authored-by: Cursor --- .../db/shared/legacy-pgdelta.seam.integration.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 0c1a590849..0858b8251d 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 @@ -29,6 +29,7 @@ import { type LegacyEdgeRuntimeRunOpts, LegacyEdgeRuntimeScript, } from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { dockerfileServiceImageRaw } from "../../../../shared/services/dockerfile-images.ts"; import { LEGACY_SUGGEST_DOCKER_INSTALL } from "../../../shared/legacy-docker-suggest.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; @@ -289,7 +290,7 @@ describe("legacyDeclarativeSeamLayer.ensureLocalPostgresImageCurrent", () => { () => { 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" }); + const { layer } = setup(dir, { dbInspectImage: dockerfileServiceImageRaw("pg") }); return Effect.gen(function* () { const seam = yield* LegacyDeclarativeSeam; const exit = yield* seam.ensureLocalPostgresImageCurrent().pipe(Effect.exit); @@ -306,7 +307,7 @@ describe("legacyDeclarativeSeamLayer.ensureLocalPostgresImageCurrent", () => { 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" }); + const { layer } = setup(dir, { dbInspectImage: dockerfileServiceImageRaw("pg") }); return Effect.gen(function* () { const seam = yield* LegacyDeclarativeSeam; const exit = yield* seam.ensureLocalPostgresImageCurrent().pipe(Effect.exit); From 86ba7c4c66a0e2da75ef9c02e570f977d372ec82 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 28 Aug 2026 20:10:19 +0200 Subject: [PATCH 5/7] fix(cli): wait on Logflare for slim Vector like docker.io Slim Vector ships sh and wget, so both families share the heredoc entrypoint. Drop leftover tests that only restated the shared docker.io spec. Co-authored-by: Cursor --- .../services/storage.service.unit.test.ts | 32 +--------- .../services/supavisor.service.unit.test.ts | 22 ++++++- .../commands/start/services/vector.service.ts | 47 +++++--------- .../services/vector.service.unit.test.ts | 19 +++--- .../postgres.service.unit.test.ts | 21 +------ .../shared/db-bootstrap/slim-runtime.ts | 10 +-- .../db-bootstrap/slim-runtime.unit.test.ts | 20 +----- .../shared/legacy-edge-runtime-image.ts | 6 -- .../legacy-edge-runtime-image.unit.test.ts | 57 +---------------- .../legacy-edge-runtime-script.layer.ts | 6 +- .../shared/legacy-pg-dump.run.unit.test.ts | 61 ++++++------------- .../src/shared/functions/deploy.unit.test.ts | 41 +------------ .../functions/functions.shared.unit.test.ts | 13 ---- 13 files changed, 74 insertions(+), 281 deletions(-) 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 9182f13302..69d8456c98 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 { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { legacyAppendStorageVectorEnv, @@ -8,10 +8,6 @@ import { type LegacyStorageEnvInput, } from "./storage.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - const baseEnvInput: LegacyStorageEnvInput = { targetMigration: "", anonKey: "anon-key", @@ -257,30 +253,4 @@ describe("legacyBuildStorageContainerSpec", () => { "postgresql://postgres:postgres@supabase_db_proj:5432/postgres", ); }); - - test("emits the wget Docker healthcheck on a slim storage image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const spec = legacyBuildStorageContainerSpec({ - ...input, - image: "ghcr.io/supabase/cli/storage:v1.70.3", - }); - expect(spec.healthcheck?.test).toEqual([ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - "http://127.0.0.1:5000/status", - ]); - }); - - test("mounts the named volume at /mnt on a slim image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const spec = legacyBuildStorageContainerSpec({ - ...input, - image: "ghcr.io/supabase/cli/storage:v1.70.3", - }); - expect(spec.binds).toEqual(["supabase_storage_proj:/mnt"]); - expect(spec.env["FILE_STORAGE_BACKEND_PATH"]).toBe("/mnt"); - }); }); diff --git a/apps/cli/src/legacy/commands/start/services/supavisor.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/supavisor.service.unit.test.ts index cc22f6f63f..9764186d91 100644 --- a/apps/cli/src/legacy/commands/start/services/supavisor.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/supavisor.service.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { legacyBuildSupavisorContainerSpec, @@ -6,6 +6,10 @@ import { type LegacySupavisorContainerSpecInput, } from "./supavisor.service.ts"; +afterEach(() => { + vi.unstubAllEnvs(); +}); + const base: LegacySupavisorContainerSpecInput = { image: "supabase/supavisor:2.0.0", projectId: "proj", @@ -127,4 +131,20 @@ describe("legacyBuildSupavisorContainerSpec", () => { expect(spec.networkId).toBe("supabase_network_proj"); expect(spec.networkAliases).toEqual(["pooler"]); }); + + test("uses busybox wget for the healthcheck on a slim pooler image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildSupavisorContainerSpec({ + ...base, + image: "ghcr.io/supabase/cli/pooler:v2.9.10", + }); + expect(spec.healthcheck?.test).toEqual([ + "CMD", + "/bin/busybox", + "wget", + "-q", + "--spider", + "http://127.0.0.1:4000/api/health", + ]); + }); }); 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 1606b56f27..d1813c2604 100644 --- a/apps/cli/src/legacy/commands/start/services/vector.service.ts +++ b/apps/cli/src/legacy/commands/start/services/vector.service.ts @@ -35,10 +35,6 @@ 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"]; @@ -348,33 +344,18 @@ export function legacyBuildVectorContainerSpec( dbId: input.dbId, }); - 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: {}, - }; + 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: {}, + }; } 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 d3669438b7..fe47e88ba7 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 @@ -301,25 +301,22 @@ describe("legacyBuildVectorContainerSpec", () => { expect(script).toContain('.appname == "supabase_kong_proj"'); }); - test("delivers vector.yaml via secretFiles and uses busybox wget on a slim image", () => { + test("slim image still waits on Logflare before exec, with the docker.io wget healthcheck", () => { 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.entrypoint).toBe("sh"); + expect(spec.secretFiles).toBeUndefined(); + expect(String(spec.cmd?.[1])).toContain( + "until wget --no-verbose --tries=1 -T 2 --spider http://supabase_analytics_proj:4000/health", + ); expect(spec.healthcheck?.test).toEqual([ "CMD", - "/bin/busybox", "wget", - "-q", + "--no-verbose", + "--tries=1", "--spider", "http://127.0.0.1:9001/health", ]); 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 904df95c28..a96520a7c7 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts @@ -1,5 +1,5 @@ import type { CliConfig } from "@supabase/config"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { LEGACY_START_DB_RESTORE_SH } from "./templates/db-restore.sh.ts"; import { LEGACY_START_DB_SCHEMA_SQL } from "./templates/db-schema.sql.ts"; @@ -19,10 +19,6 @@ import { const POSTGRES_CONFIG_HEADER = "\n# supabase [db.settings] configuration\n"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - function baseDb(overrides: Partial = {}): CliConfig["db"] { return { port: 54322, @@ -233,21 +229,6 @@ describe("legacyBuildPostgresStartContainerSpec", () => { }); }); - test("healthcheck stays the plain pg_isready probe on docker.io even with the slim flag set", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const spec = legacyBuildPostgresStartContainerSpec(baseInput()); - expect(spec.healthcheck?.test).toEqual([ - "CMD", - "pg_isready", - "-U", - "postgres", - "-h", - "127.0.0.1", - "-p", - "5432", - ]); - }); - test("port binding maps the configured db.port to container port 5432", () => { const spec = legacyBuildPostgresStartContainerSpec(baseInput({ db: baseDb({ port: 12345 }) })); expect(spec.ports).toEqual([{ hostPort: "12345", containerPort: "5432" }]); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts index 953617173e..8df97fada9 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts @@ -7,11 +7,11 @@ * `/bin/sh -c` (`docker-create-args.ts`). Distroless images with no `/bin/sh` * (auth, studio, pg-meta) therefore cannot carry a Docker healthcheck through * this CLI — omit it and let `legacyCheckContainerReady` treat `Running` as - * ready, the same as PostgREST. Elixir/busybox images (realtime, analytics) - * and Vector do ship `/bin/sh` plus a wget applet, so they keep an exec-form - * probe that the CLI quotes into CMD-SHELL. Postgres, storage, and - * edge-runtime now match docker.io (root start, `sh`/`wget`), so they share - * the docker.io specs; the flag only rewrites their image names. + * ready, the same as PostgREST. Elixir/busybox images (realtime, analytics, + * pooler) ship `/bin/sh` plus a wget applet, so they keep an exec-form probe + * that the CLI quotes into CMD-SHELL. Vector, Postgres, storage, and + * edge-runtime match docker.io (`sh`/`wget`), so they share those 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/slim-runtime.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.unit.test.ts index 3ba0844a3c..9147974b17 100644 --- 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 @@ -1,22 +1,6 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } 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); - }); -}); +import { LEGACY_SLIM_BUSYBOX, legacySlimBusyboxWgetHealthcheck } from "./slim-runtime.ts"; describe("legacySlimBusyboxWgetHealthcheck", () => { it("invokes busybox wget --spider", () => { 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 3841ed4d4c..3d3043ba7a 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.ts @@ -60,9 +60,3 @@ export const legacyResolveEdgeRuntimeImage = ( workdir: string, denoVersion: number, ) => resolveEdgeRuntimeImage(fs, path, workdir, denoVersion); - -/** - * Same resolution as {@link legacyResolveEdgeRuntimeImage}. The slim image now - * ships `sh`, so shell-entrypoint callers no longer need a docker.io pin. - */ -export const legacyResolveEdgeRuntimeShellImage = legacyResolveEdgeRuntimeImage; diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts index babd59c64f..6212f9a2ba 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 @@ -11,10 +11,7 @@ import { dockerfileServiceImageRaw, } from "../../shared/services/dockerfile-images.ts"; import { toSlimImage } from "../../shared/services/slim-images.ts"; -import { - legacyResolveEdgeRuntimeImage, - legacyResolveEdgeRuntimeShellImage, -} from "./legacy-edge-runtime-image.ts"; +import { legacyResolveEdgeRuntimeImage } from "./legacy-edge-runtime-image.ts"; const currentEdgeRuntime = dockerfileServiceImageRaw("edgeruntime"); const currentEdgeRuntimeTag = currentEdgeRuntime.split(":")[1] ?? ""; @@ -26,13 +23,6 @@ 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-")); @@ -46,18 +36,6 @@ 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 }); @@ -122,39 +100,6 @@ describe("legacyResolveEdgeRuntimeImage", () => { ); }); - it.effect("keeps a historical shell pin on docker.io", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); - mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(dir, "supabase", ".temp", "edge-runtime-version"), "v9.9.9\n"); - return resolveShell(dir, 2).pipe( - Effect.tap((image) => - Effect.sync(() => { - expect(image).toBe("supabase/edge-runtime:v9.9.9"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - it.effect("rewrites the shell resolver's current pin onto the slim base", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); - mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); - writeFileSync( - join(dir, "supabase", ".temp", "edge-runtime-version"), - `${currentEdgeRuntimeTag}\n`, - ); - return resolveShell(dir, 2).pipe( - Effect.tap((image) => - Effect.sync(() => { - expect(image).toBe(toSlimImage("edgeruntime", currentEdgeRuntime)); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - it.effect("keeps a deno1-tag pin on docker.io, where that tag exists", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); 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 626db29bed..17e1a65d6f 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 { legacyResolveEdgeRuntimeShellImage } from "./legacy-edge-runtime-image.ts"; +import { legacyResolveEdgeRuntimeImage } from "./legacy-edge-runtime-image.ts"; import { LegacyEdgeRuntimeScriptError } from "./legacy-edge-runtime-script.errors.ts"; import { LEGACY_EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL, @@ -101,10 +101,8 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( (error) => new LegacyEdgeRuntimeScriptError({ message: error.message }), ), )).denoVersion; - // Slim edge-runtime now ships `sh`, so the heredoc entrypoint - // works on both image families. const registryImage = legacyGetRegistryImageUrl( - yield* legacyResolveEdgeRuntimeShellImage(fs, path, workdir, denoVersion), + yield* legacyResolveEdgeRuntimeImage(fs, path, workdir, denoVersion), ); const port = yield* allocateFreeHostPort; const startCmd = legacyBuildEdgeRuntimeStartCmd({ port, debug }).join(" "); diff --git a/apps/cli/src/legacy/shared/legacy-pg-dump.run.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.run.unit.test.ts index 4cfd6f2e04..bd07fee589 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 @@ -1,18 +1,11 @@ import { Effect, Layer, Option } from "effect"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } 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, { @@ -41,42 +34,24 @@ const runtimeInfoLayer = Layer.succeed(RuntimeInfo, { pid: 1234, }); -function runStreamPgDump(image: string): LegacyDockerRunOpts { - const docker = mockDockerRun(); - const layer = Layer.mergeAll( - docker.layer, - runtimeInfoLayer, - Layer.succeed(LegacyNetworkIdFlag, Option.none()), - ); - Effect.runSync( - legacyStreamPgDump({ - image, - script: "pg_dump", - env: {}, - onStdout: () => Effect.void, - }).pipe(Effect.provide(layer)), - ); - const opts = docker.lastOpts; - if (opts === undefined) throw new Error("docker.runStream was never called"); - return opts; -} - describe("legacyStreamPgDump entrypoint wiring", () => { - test("docker.io: keeps the image's own entrypoint, running bash under it", () => { - const opts = runStreamPgDump(DOCKER_IO_IMAGE); - expect(opts.entrypoint).toBeUndefined(); - expect(opts.cmd).toEqual(["bash", "-c", "pg_dump", "--"]); - }); - - test("SUPABASE_USE_SLIM_IMAGES unset: a ghcr.io-shaped image still keeps the docker.io cmd shape (flag-off byte-identity)", () => { - const opts = runStreamPgDump(SLIM_IMAGE); - expect(opts.entrypoint).toBeUndefined(); - expect(opts.cmd).toEqual(["bash", "-c", "pg_dump", "--"]); - }); - - test("slim image + flag on: uses the same bash cmd as docker.io", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const opts = runStreamPgDump(SLIM_IMAGE); + test("keeps the image entrypoint and runs bash -c, not dump-bash", () => { + const docker = mockDockerRun(); + const layer = Layer.mergeAll( + docker.layer, + runtimeInfoLayer, + Layer.succeed(LegacyNetworkIdFlag, Option.none()), + ); + Effect.runSync( + legacyStreamPgDump({ + image: "supabase/postgres:17.4.1.030", + 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"); expect(opts.entrypoint).toBeUndefined(); expect(opts.cmd).toEqual(["bash", "-c", "pg_dump", "--"]); }); diff --git a/apps/cli/src/shared/functions/deploy.unit.test.ts b/apps/cli/src/shared/functions/deploy.unit.test.ts index a1c5486065..7eb7be3ffb 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 { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { buildDockerBinds, formatDockerBind, type ResolvedDeployFunctionConfig } from "./deploy.ts"; import { FunctionImportNotDirectoryError } from "./deploy.errors.ts"; @@ -645,42 +645,3 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil } }); }); - -describe("buildDockerBinds — edge-runtime Deno-cache volume selection", () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("mounts the shared volume at /root/.cache/deno by default", async () => { - const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( - {}, - 'Deno.serve(() => new Response("ok"));\n', - ); - - try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config); - expect(binds.map(formatDockerBind)).toContain( - "supabase_edge_runtime_test-project:/root/.cache/deno:rw", - ); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("uses the shared /root/.cache/deno volume under the slim flag", async () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( - {}, - 'Deno.serve(() => new Response("ok"));\n', - ); - - try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config); - const formatted = binds.map(formatDockerBind); - expect(formatted).toContain("supabase_edge_runtime_test-project:/root/.cache/deno:rw"); - expect(formatted).not.toContain("supabase_edge_runtime_slim_test-project:/home/nonroot:rw"); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); -}); diff --git a/apps/cli/src/shared/functions/functions.shared.unit.test.ts b/apps/cli/src/shared/functions/functions.shared.unit.test.ts index 72d94bd4ac..5eb8806232 100644 --- a/apps/cli/src/shared/functions/functions.shared.unit.test.ts +++ b/apps/cli/src/shared/functions/functions.shared.unit.test.ts @@ -34,13 +34,6 @@ describe("edgeRuntimeImage", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); expect(edgeRuntimeImage("v1.73.0")).toBe("supabase/edge-runtime:v1.73.0"); }); - - it("keeps the deno1 tag on the docker.io image while the flag is off", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); - expect(edgeRuntimeImage(DENO1_EDGE_RUNTIME_VERSION)).toBe( - `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`, - ); - }); }); describe("resolveEdgeRuntimeVersionPin", () => { @@ -50,10 +43,4 @@ describe("resolveEdgeRuntimeVersionPin", () => { 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); - }); }); From ad8e57993a0b1c7af70aa27ec84324daefd443c3 Mon Sep 17 00:00:00 2001 From: avallete Date: Sat, 29 Aug 2026 12:16:55 +0200 Subject: [PATCH 6/7] fix(cli): align slim runtime with published ghcr tags Auth, studio, and pg-meta now share docker.io healthchecks after the latest slim images. Elixir probes stay on PATH wget; analytics keeps the image entrypoint because the binary lives at /app/bin/logflare. Co-authored-by: Cursor --- apps/cli-go/pkg/config/templates/Dockerfile | 8 ++-- .../commands/gen/types/types.handler.ts | 6 +-- .../gen/types/types.integration.test.ts | 44 ------------------- .../commands/start/services/gotrue.service.ts | 34 ++++++-------- .../services/gotrue.service.unit.test.ts | 20 +-------- .../start/services/logflare.service.ts | 7 ++- .../services/logflare.service.unit.test.ts | 8 ++-- .../start/services/pg-meta.service.ts | 25 ++++------- .../services/pg-meta.service.unit.test.ts | 21 +-------- .../start/services/realtime.service.ts | 4 +- .../services/realtime.service.unit.test.ts | 8 ++-- .../commands/start/services/studio.service.ts | 25 ++++------- .../services/studio.service.unit.test.ts | 15 +------ .../start/services/supavisor.service.ts | 6 +-- .../services/supavisor.service.unit.test.ts | 8 ++-- .../shared/db-bootstrap/slim-runtime.ts | 26 +++++------ .../db-bootstrap/slim-runtime.unit.test.ts | 12 ++--- 17 files changed, 78 insertions(+), 199 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 032515d6f7..a6150865c7 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.166 AS pg +FROM supabase/postgres:17.6.1.167 AS pg # Append to ServiceImages when adding new dependencies below FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.30.2 AS mailpit @@ -9,11 +9,11 @@ FROM supabase/studio:2026.08.24-sha-8ec45b2 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.3 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector -FROM supabase/supavisor:2.9.10 AS supavisor +FROM supabase/supavisor:2.9.12 AS supavisor FROM supabase/gotrue:v2.196.0 AS gotrue -FROM supabase/realtime:v2.129.3 AS realtime +FROM supabase/realtime:v2.130.0 AS realtime FROM supabase/storage-api:v1.72.1 AS storage -FROM supabase/logflare:1.50.4 AS logflare +FROM supabase/logflare:1.50.6 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ FROM supabase/migra:3.0.1663481299 AS migra diff --git a/apps/cli/src/legacy/commands/gen/types/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index 39396baa52..f6c2def66a 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -45,7 +45,6 @@ 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, @@ -449,9 +448,8 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le networkMode, ...env.flatMap((entry) => ["--env", entry]), 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"]), + "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 da4d834594..1676bc1ff7 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 @@ -2381,50 +2381,6 @@ 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/start/services/gotrue.service.ts b/apps/cli/src/legacy/commands/start/services/gotrue.service.ts index 8fb978d503..976eef8a7f 100644 --- a/apps/cli/src/legacy/commands/start/services/gotrue.service.ts +++ b/apps/cli/src/legacy/commands/start/services/gotrue.service.ts @@ -54,7 +54,6 @@ 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, @@ -646,7 +645,6 @@ 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, @@ -654,25 +652,19 @@ export function legacyBuildGotrueContainerSpec( env, binds: [], exposedPorts: [{ containerPort: LEGACY_GOTRUE_PORT }], - // 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, - }, - }), + 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 191d936c38..65450f6a0a 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 { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { legacyBuildGotrueContainerSpec, @@ -11,10 +11,6 @@ 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 = { @@ -712,18 +708,4 @@ describe("legacyBuildGotrueContainerSpec", () => { "postgresql://supabase_auth_admin:secret@supabase_db_proj:5432/postgres", ); }); - - test("omits the wget healthcheck only when the slim flag is on", () => { - const input = { - image: "ghcr.io/supabase/cli/auth:v2.196.0", - projectId: "proj", - networkId: "supabase_network_proj", - dbUrl: "postgresql://postgres:secret@127.0.0.1:54322/postgres", - env: baseEnvInput, - }; - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); - expect(legacyBuildGotrueContainerSpec(input).healthcheck?.test[1]).toBe("wget"); - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - expect(legacyBuildGotrueContainerSpec(input).healthcheck).toBeUndefined(); - }); }); diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.ts index e96cd19fe5..732a11ef07 100644 --- a/apps/cli/src/legacy/commands/start/services/logflare.service.ts +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.ts @@ -20,7 +20,7 @@ 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, + legacySlimWgetHealthcheck, legacyUsesSlimRuntime, } from "../../../shared/db-bootstrap/slim-runtime.ts"; @@ -59,6 +59,9 @@ const LEGACY_LOGFLARE_API_KEY = "api-key"; * Docker's 10s SIGTERM grace (upstream hang). Forward TERM, wait 3s, then * KILL. Interrupted `wait` is >128; a second `wait` recovers the BEAM's * status unless it was already reaped (127). + * + * Slim analytics ships `/app/bin/logflare` (WORKDIR `/app`), so this + * `./logflare` wrapper is docker.io-only. Flag-on keeps the image entrypoint. */ const LEGACY_LOGFLARE_ENTRYPOINT_SCRIPT = "cat <<'EOF' > run.sh && exec sh run.sh\n" + @@ -167,7 +170,7 @@ export function legacyBuildLogflareContainerSpec( exposedPorts: [{ containerPort: "4000" }], ports: [{ hostPort: String(input.port), containerPort: "4000" }], healthcheck: slim - ? legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:4000/health", { + ? legacySlimWgetHealthcheck("http://127.0.0.1:4000/health", { startPeriodSeconds: 10, }) : { 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 c9dd72a480..e8e975ae9a 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 @@ -122,19 +122,19 @@ 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", () => { + test("keeps the image entrypoint and uses 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", + image: "ghcr.io/supabase/cli/analytics:v1.50.6", }); expect(spec.entrypoint).toBeUndefined(); expect(spec.cmd).toBeUndefined(); expect(spec.healthcheck?.test).toEqual([ "CMD", - "/bin/busybox", "wget", - "-q", + "--no-verbose", + "--tries=1", "--spider", "http://127.0.0.1:4000/health", ]); 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 d90de80da9..0d1d14e27c 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,7 +16,6 @@ */ 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; @@ -65,21 +64,15 @@ export function legacyBuildPgMetaContainerSpec( PG_META_DB_PASSWORD: input.dbPassword, }, binds: [], - // 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, - }, - }), + 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 85221dbdfc..a63048a97f 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,11 +1,7 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } 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({ @@ -64,19 +60,4 @@ 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 127d7405e5..924c6d1fd7 100644 --- a/apps/cli/src/legacy/commands/start/services/realtime.service.ts +++ b/apps/cli/src/legacy/commands/start/services/realtime.service.ts @@ -18,7 +18,7 @@ import { } from "../../../shared/db-bootstrap/realtime-env.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; import { - legacySlimBusyboxWgetHealthcheck, + legacySlimWgetHealthcheck, legacyUsesSlimRuntime, } from "../../../shared/db-bootstrap/slim-runtime.ts"; import { legacyStartInternalDbPassword } from "../../../shared/db-bootstrap/internal-db-connection.ts"; @@ -62,7 +62,7 @@ export function legacyBuildRealtimeContainerSpec( binds: [], exposedPorts: [{ containerPort: "4000" }], healthcheck: legacyUsesSlimRuntime(input.image) - ? legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:4000/api/ping", { + ? legacySlimWgetHealthcheck("http://127.0.0.1:4000/api/ping", { header: `Host:${LEGACY_REALTIME_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 5c2b987ea8..9d8a0b7b8a 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 @@ -70,17 +70,17 @@ describe("legacyBuildRealtimeContainerSpec", () => { expect(spec.env["DB_PASSWORD"]).toBe("another-secret"); }); - test("uses busybox wget for the healthcheck on a slim realtime image", () => { + test("uses 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", + image: "ghcr.io/supabase/cli/realtime:v2.130.0", }); expect(spec.healthcheck?.test).toEqual([ "CMD", - "/bin/busybox", "wget", - "-q", + "--no-verbose", + "--tries=1", "--spider", "--header", "Host:realtime-dev", 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 15789dbb0d..291b575cc0 100644 --- a/apps/cli/src/legacy/commands/start/services/studio.service.ts +++ b/apps/cli/src/legacy/commands/start/services/studio.service.ts @@ -24,7 +24,6 @@ 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; @@ -187,21 +186,15 @@ export function legacyBuildStudioContainerSpec( containerName: input.containerName, env: legacyBuildStudioEnv({ ...input.env, containerSnippetsPath }), binds, - // 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, - }, - }), + 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 fe534cd300..80728d823d 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 { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { legacyBuildStudioContainerSpec, @@ -6,10 +6,6 @@ import { type LegacyBuildStudioEnvInput, } from "./studio.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - const baseEnvInput: LegacyBuildStudioEnvInput = { dbPassword: "postgres", workdir: "/project", @@ -173,13 +169,4 @@ describe("legacyBuildStudioContainerSpec", () => { expect(spec.binds).toEqual(["/project/supabase/snippets:/project/supabase/snippets:rw"]); }); - - test("omits the Docker healthcheck on a slim distroless studio image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - const spec = legacyBuildStudioContainerSpec({ - ...baseSpecInput, - image: "ghcr.io/supabase/cli/studio:2026.08.17-sha-0c1da8f", - }); - expect(spec.healthcheck).toBeUndefined(); - }); }); diff --git a/apps/cli/src/legacy/commands/start/services/supavisor.service.ts b/apps/cli/src/legacy/commands/start/services/supavisor.service.ts index e2d0a45ac5..44d5e1cb5a 100644 --- a/apps/cli/src/legacy/commands/start/services/supavisor.service.ts +++ b/apps/cli/src/legacy/commands/start/services/supavisor.service.ts @@ -41,7 +41,7 @@ import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; import { - legacySlimBusyboxWgetHealthcheck, + legacySlimWgetHealthcheck, legacyUsesSlimRuntime, } from "../../../shared/db-bootstrap/slim-runtime.ts"; import { @@ -184,9 +184,9 @@ export function legacyBuildSupavisorContainerSpec( { containerPort: LEGACY_SUPAVISOR_TRANSACTION_PORT }, ], ports: [{ hostPort: String(input.port), containerPort: dockerPort }], - // The slim supavisor image is distroless plus /bin/busybox (no curl). + // Slim pooler ships wget, not curl. healthcheck: legacyUsesSlimRuntime(input.image) - ? legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:4000/api/health") + ? legacySlimWgetHealthcheck("http://127.0.0.1:4000/api/health") : { test: [ "CMD", diff --git a/apps/cli/src/legacy/commands/start/services/supavisor.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/supavisor.service.unit.test.ts index 9764186d91..94746efc3c 100644 --- a/apps/cli/src/legacy/commands/start/services/supavisor.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/supavisor.service.unit.test.ts @@ -132,17 +132,17 @@ describe("legacyBuildSupavisorContainerSpec", () => { expect(spec.networkAliases).toEqual(["pooler"]); }); - test("uses busybox wget for the healthcheck on a slim pooler image", () => { + test("uses wget for the healthcheck on a slim pooler image", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = legacyBuildSupavisorContainerSpec({ ...base, - image: "ghcr.io/supabase/cli/pooler:v2.9.10", + image: "ghcr.io/supabase/cli/pooler:v2.9.12", }); expect(spec.healthcheck?.test).toEqual([ "CMD", - "/bin/busybox", "wget", - "-q", + "--no-verbose", + "--tries=1", "--spider", "http://127.0.0.1:4000/api/health", ]); 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 8df97fada9..27f0a9f1c8 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts @@ -1,17 +1,13 @@ /** - * 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. + * Slim-image runtime contracts that still differ from docker.io. Spec builders + * switch on {@link usesSlimImageRuntime} so flag-off stays byte-identical even + * if a caller passes a ghcr-shaped override. * - * Docker CLI `--health-cmd` is always stored as `CMD-SHELL` and executed with - * `/bin/sh -c` (`docker-create-args.ts`). Distroless images with no `/bin/sh` - * (auth, studio, pg-meta) therefore cannot carry a Docker healthcheck through - * this CLI — omit it and let `legacyCheckContainerReady` treat `Running` as - * ready, the same as PostgREST. Elixir/busybox images (realtime, analytics, - * pooler) ship `/bin/sh` plus a wget applet, so they keep an exec-form probe - * that the CLI quotes into CMD-SHELL. Vector, Postgres, storage, and - * edge-runtime match docker.io (`sh`/`wget`), so they share those specs; the - * flag only rewrites their image names. + * Auth, studio, pg-meta, Vector, Postgres, storage, and edge-runtime share the + * docker.io specs (`sh`/`wget`/`node`). Elixir images (realtime, analytics, + * pooler) ship busybox `wget` on PATH but not `curl`, so they keep a wget + * probe instead of docker.io's `curl --head`. Slim analytics also keeps the + * image entrypoint (`/app/bin/logflare`, not docker.io's `./logflare`). */ import { usesSlimImageRuntime } from "../../../shared/services/slim-images.ts"; @@ -21,9 +17,7 @@ export function legacyUsesSlimRuntime(image: string): boolean { return usesSlimImageRuntime(image); } -export const LEGACY_SLIM_BUSYBOX = "/bin/busybox"; - -export function legacySlimBusyboxWgetHealthcheck( +export function legacySlimWgetHealthcheck( url: string, opts: { readonly header?: string; readonly startPeriodSeconds?: number } = {}, ): { @@ -33,7 +27,7 @@ export function legacySlimBusyboxWgetHealthcheck( readonly retries: number; readonly startPeriodSeconds?: number; } { - const test = ["CMD", LEGACY_SLIM_BUSYBOX, "wget", "-q", "--spider"]; + const test = ["CMD", "wget", "--no-verbose", "--tries=1", "--spider"]; if (opts.header !== undefined) { test.push("--header", opts.header); } 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 index 9147974b17..7cb7442793 100644 --- 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 @@ -1,18 +1,18 @@ import { describe, expect, it } from "vitest"; -import { LEGACY_SLIM_BUSYBOX, legacySlimBusyboxWgetHealthcheck } from "./slim-runtime.ts"; +import { legacySlimWgetHealthcheck } from "./slim-runtime.ts"; -describe("legacySlimBusyboxWgetHealthcheck", () => { - it("invokes busybox wget --spider", () => { - const check = legacySlimBusyboxWgetHealthcheck("http://127.0.0.1:4000/health", { +describe("legacySlimWgetHealthcheck", () => { + it("invokes wget --spider", () => { + const check = legacySlimWgetHealthcheck("http://127.0.0.1:4000/health", { header: "Host:realtime-dev", startPeriodSeconds: 10, }); expect(check.test).toEqual([ "CMD", - LEGACY_SLIM_BUSYBOX, "wget", - "-q", + "--no-verbose", + "--tries=1", "--spider", "--header", "Host:realtime-dev", From 5db428081cbfcddb992e4bbf67c4fccd566cfaaf Mon Sep 17 00:00:00 2001 From: avallete Date: Sat, 29 Aug 2026 12:27:52 +0200 Subject: [PATCH 7/7] chore(cli): drop unused vitest import left after slim pg-meta test removal Co-authored-by: Cursor --- apps/cli/src/legacy/commands/gen/types/types.integration.test.ts | 1 - 1 file changed, 1 deletion(-) 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 1676bc1ff7..a8f8f33e2f 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,7 +3,6 @@ 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,