diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index fa96d9b4d4..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.165 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,10 +9,10 @@ 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.12 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/realtime:v2.130.0 AS realtime +FROM supabase/storage-api:v1.72.1 AS storage 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 diff --git a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts index dacfdc53f2..ab2177dca2 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts @@ -253,7 +253,7 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy // real container path; the dry-run script above is image-independent). The // file is never opened on dry-run, so it is created/truncated only here, // after the dry-run early return. - const image = yield* legacyResolveDbImage( + const { image } = yield* legacyResolveDbImage( fs, path, cliSettings.workdir, diff --git a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index 7c7de4fd56..dc60d9ae8e 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -659,7 +659,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe( Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), ); - const image = yield* legacyResolveDbImage( + const { image } = yield* legacyResolveDbImage( fs, path, cliSettings.workdir, 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..52478e812d 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, beforeEach, vi } from "vitest"; import { mockLegacyCliSettings, @@ -28,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"; @@ -115,12 +117,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 +279,59 @@ describe("legacyDeclarativeSeamLayer.ensureLocalDatabaseStarted", () => { }, ); }); + +describe("legacyDeclarativeSeamLayer.ensureLocalPostgresImageCurrent", () => { + beforeEach(() => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + }); + 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: dockerfileServiceImageRaw("pg") }); + 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", + ); + expect((error as LegacyDeclarativeShadowDbError).message).toContain( + "same SUPABASE_USE_SLIM_IMAGES setting", + ); + expect((error as LegacyDeclarativeShadowDbError).message).not.toContain("--no-backup"); + rmSync(dir, { recursive: true, force: true }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("bails out when inspect succeeds but the image name is unparseable", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-seam-")); + const { layer } = setup(dir, { dbInspectImage: "" }); + 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)); + }); + + 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: dockerfileServiceImageRaw("pg") }); + 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..b2d05120dc 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 { @@ -168,7 +169,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( }), ), ); - const image = yield* legacyResolveDbImage( + const { image } = yield* legacyResolveDbImage( fs, path, cliSettings.workdir, @@ -262,12 +263,23 @@ 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) { + if (actual.length === 0 || actualTag.length === 0 || expectedTag.length === 0) { return; } + // 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 === expectedTag) { + return; + } + const remediation = + familyMismatch && actualTag === expectedTag + ? "The tags match but the image family does not (slim vs docker.io). Run supabase stop, then supabase start with the same SUPABASE_USE_SLIM_IMAGES setting before syncing declarative schemas." + : "Run supabase stop --all --no-backup, then supabase start before syncing declarative schemas."; return yield* Effect.fail( new LegacyDeclarativeShadowDbError({ - message: `local Postgres container image is stale: running ${actual} but expected ${expected}. Run supabase stop --all --no-backup, then supabase start before syncing declarative schemas.`, + message: `local Postgres container image is stale: running ${actual} but expected ${expected}. ${remediation}`, }), ); }), 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..72930cf1b1 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 @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { afterEach, describe, expect, it } from "@effect/vitest"; +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; @@ -387,8 +387,12 @@ const currentBranchPath = (workdir: string) => join(workdir, "supabase", ".branches", "_current_branch"); describe("legacy db start", () => { + beforeEach(() => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + }); afterEach(() => { delete process.env["SUPABASE_NETWORK_ID"]; + vi.unstubAllEnvs(); }); it.live("reports an already-running database without starting a container", () => { 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..f6c2def66a 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -440,13 +440,14 @@ 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), + pgmetaImage, "node", "dist/server/server.js", ]; 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..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 @@ -2371,6 +2371,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); 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..f125292570 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) { @@ -127,8 +132,8 @@ describe("parseDatabaseUrl", () => { describe("resolvePgmetaImage", () => { it("uses the default pgmeta version when no override is given", () => { - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, () => - resolvePgmetaImage(), + const image = withEnv("SUPABASE_USE_SLIM_IMAGES", undefined, () => + withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, () => resolvePgmetaImage()), ); expect(image).toContain("postgres-meta"); }); @@ -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..5c9c61a5ec 100644 --- a/apps/cli/src/legacy/commands/services/services.handler.ts +++ b/apps/cli/src/legacy/commands/services/services.handler.ts @@ -144,13 +144,13 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le const postgresImage = tomlValues === null ? undefined - : yield* legacyResolveDbImage( + : (yield* legacyResolveDbImage( fs, path, cliSettings.workdir, tomlValues.majorVersion, Option.getOrUndefined(tomlValues.orioledbVersion), - ); + )).image; const edgeRuntimeImage = tomlValues === null ? undefined @@ -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/services/services.integration.test.ts b/apps/cli/src/legacy/commands/services/services.integration.test.ts index 90bcf3fbef..b142e5375c 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -20,10 +20,8 @@ import { processEnvLayer, } from "../../../../tests/helpers/mocks.ts"; import { mockLegacyTelemetryStateTracked } from "../../../../tests/helpers/legacy-mocks.ts"; -import { - listLocalServiceVersions, - postgresImageForDbMajorVersion, -} from "../../../shared/services/services.shared.ts"; +import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; +import { postgresImageForDbMajorVersion } from "../../../shared/services/services.shared.ts"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; import { processControlLayer } from "../../../shared/runtime/process-control.layer.ts"; import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; @@ -31,15 +29,7 @@ import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; import { legacyServicesCommand } from "./services.command.ts"; import { legacyServices } from "./services.handler.ts"; -const LOCAL_POSTGRES_SERVICE = listLocalServiceVersions().find( - (service) => service.name === "supabase/postgres", -); - -if (LOCAL_POSTGRES_SERVICE === undefined) { - throw new Error("Missing supabase/postgres in local service versions."); -} - -const LOCAL_POSTGRES_VERSION = LOCAL_POSTGRES_SERVICE.local; +const LOCAL_POSTGRES_VERSION = dockerfileServiceImageRaw("pg").split(":")[1] ?? ""; function setup( opts: { 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/logflare.service.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.ts index d51ad00fb1..bed008dfb7 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 { + legacySlimWgetHealthcheck, + 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"; @@ -134,6 +138,7 @@ export function legacyBuildLogflareContainerSpec( }; const binds: Array = []; + const slim = legacyUsesSlimRuntime(input.image); if (input.backend === "bigquery") { const hostJwtPath = join(input.workdir, input.gcpJwtPath); @@ -156,13 +161,25 @@ export function legacyBuildLogflareContainerSpec( 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 + ? legacySlimWgetHealthcheck("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..f75f28c111 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,40 @@ describe("legacyBuildLogflareContainerSpec", () => { }); expect(spec.binds).toEqual([`${join("/workdir", "")}:/opt/app/rel/logflare/bin/gcloud.json`]); }); + + test("bigquery on a slim analytics image uses the same gcloud.json bind as docker.io", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const spec = legacyBuildLogflareContainerSpec({ + ...base, + image: "ghcr.io/supabase/cli/analytics:v1.50.6", + backend: "bigquery", + gcpProjectId: "my-project", + gcpProjectNumber: "123456", + gcpJwtPath: "gcloud.json", + }); + expect(spec.binds).toEqual([ + `${join("/workdir", "gcloud.json")}:/opt/app/rel/logflare/bin/gcloud.json`, + ]); + expect(spec.env.GOOGLE_APPLICATION_CREDENTIALS).toBeUndefined(); + }); + + test("overrides the entrypoint and uses wget on a slim analytics image", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); + const slim = legacyBuildLogflareContainerSpec({ + ...base, + image: "ghcr.io/supabase/cli/analytics:v1.50.6", + }); + const dockerIo = legacyBuildLogflareContainerSpec(base); + expect(slim.entrypoint).toBe(dockerIo.entrypoint); + expect(slim.cmd).toEqual(dockerIo.cmd); + expect(slim.healthcheck?.test).toEqual([ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:4000/health", + ]); + expect(slim.healthcheck?.startPeriodSeconds).toBe(10); + }); }); 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..924c6d1fd7 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 { + legacySlimWgetHealthcheck, + 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) + ? legacySlimWgetHealthcheck("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..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 @@ -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 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.130.0", + }); + expect(spec.healthcheck?.test).toEqual([ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--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..dc9b621a17 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,8 @@ export function legacyBuildStorageEnv(input: LegacyStorageEnvInput): Record { const enabled = legacyBuildStorageEnv({ ...baseEnvInput, imageTransformationEnabled: true }); expect(enabled["ENABLE_IMAGE_TRANSFORMATION"]).toBe("true"); + expect(enabled["IMAGE_TRANSFORMATION_ENABLED"]).toBe("true"); }); test("IMGPROXY_URL always points at the imgproxy container regardless of the gate", () => { @@ -238,12 +239,14 @@ describe("legacyBuildStorageContainerSpec", () => { imageTransformationEnabled: true, }); expect(withImgproxy.env["ENABLE_IMAGE_TRANSFORMATION"]).toBe("true"); + expect(withImgproxy.env["IMAGE_TRANSFORMATION_ENABLED"]).toBe("true"); const withoutImgproxy = legacyBuildStorageContainerSpec({ ...input, imageTransformationEnabled: false, }); expect(withoutImgproxy.env["ENABLE_IMAGE_TRANSFORMATION"]).toBe("false"); + expect(withoutImgproxy.env["IMAGE_TRANSFORMATION_ENABLED"]).toBe("false"); }); test("propagates the vector-buckets flag through to the container env", () => { 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..44d5e1cb5a 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 { + legacySlimWgetHealthcheck, + 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, - }, + // Slim pooler ships wget, not curl. + healthcheck: legacyUsesSlimRuntime(input.image) + ? legacySlimWgetHealthcheck("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/supavisor.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/supavisor.service.unit.test.ts index cc22f6f63f..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 @@ -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 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.12", + }); + expect(spec.healthcheck?.test).toEqual([ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:4000/api/health", + ]); + }); }); 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..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 @@ -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,27 @@ describe("legacyBuildVectorContainerSpec", () => { expect(script).toContain('"supabase_vector_proj"'); expect(script).toContain('.appname == "supabase_kong_proj"'); }); + + 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).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", + "wget", + "--no-verbose", + "--tries=1", + "--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..a36c9dcef2 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -810,6 +810,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta realtimeMaxHeaderLength, storageFileSizeLimit, postgresImage, + postgresConfigImage, serviceVersionOverrides, dbHealthTimeoutSeconds, storageTargetMigration, @@ -1575,7 +1576,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta jwtExpiry: values.authJwtExpiry, projectId, networkId, - configImage: postgresImage, + configImage: postgresConfigImage, rootKey: values.rootKey, // `fromBackup` stays unset: `supabase start` always calls the DB // bootstrap with an empty `fromBackup` — only `db start` ever sets it. @@ -1790,9 +1791,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 +1982,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 +2022,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..9413ebb03f 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Fiber, Layer, Option, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; @@ -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 }; } /** @@ -561,6 +564,13 @@ const VAULT_ENCRYPTED = "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; describe("legacy start integration", () => { + beforeEach(() => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + describe("--exclude validation", () => { it.live("warns on stderr for an invalid --exclude value, even when already running", () => { const { layer, out } = setup({ @@ -2645,6 +2655,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..44235e2e1c --- /dev/null +++ b/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts @@ -0,0 +1,153 @@ +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 { + return PULL_ALIASES.map((alias) => toSlimImage(alias, dockerfileServiceImageRaw(alias))); +} + +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/bootstrap-config.ts b/apps/cli/src/legacy/shared/db-bootstrap/bootstrap-config.ts index 1194aa76ba..aac6a83c1b 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/bootstrap-config.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/bootstrap-config.ts @@ -72,8 +72,10 @@ export interface LegacyDbBootstrapConfig { readonly realtimeIpVersion: "IPv4" | "IPv6"; readonly realtimeMaxHeaderLength: number; readonly storageFileSizeLimit: CliConfig["storage"]["file_size_limit"]; - /** Pre-registry-resolution image reference (`utils.Config.Db.Image`) — the caller still resolves the registry candidate itself, see this module's header. */ + /** Pull/create image (`utils.Config.Db.Image` after slim rewrite). The caller still resolves the registry candidate itself. */ readonly postgresImage: string; + /** Unprefixed docker.io identity for INITDB version-compare. Never a slim ghcr ref. */ + readonly postgresConfigImage: string; readonly serviceVersionOverrides: LocalServiceVersionOverrides; readonly dbHealthTimeoutSeconds: number; readonly storageTargetMigration: string; @@ -285,7 +287,7 @@ export const legacyResolveDbBootstrapConfig = ( // linked-project pin written by `supabase link`) BEFORE either caller reads it // (`pkg/config/config.go:827-863`) — never fails (a missing/unreadable pin file resolves to // the embedded default), so no wrap needed. - const postgresImage = yield* legacyResolveDbImage( + const { image: postgresImage, configImage: postgresConfigImage } = yield* legacyResolveDbImage( fs, path, workdir, @@ -342,6 +344,7 @@ export const legacyResolveDbBootstrapConfig = ( realtimeMaxHeaderLength, storageFileSizeLimit, postgresImage, + postgresConfigImage, serviceVersionOverrides, dbHealthTimeoutSeconds, storageTargetMigration, 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..b38c59f478 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,10 @@ * run-to-completion container on the SAME Docker network as `db` — Go's * `DockerStart` defaults `NetworkMode` to `utils.NetId` when unset, * `docker.go:379-383`), each gated on its own service's `enabled` flag and none - * of which touch `conn` directly: + * of which touch `conn` directly. The realtime one-shot still + * runs so user migrations see the tenant before long-running + * containers boot. Storage and auth use the resolved image and + * the same argv on both families. * - `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 +805,9 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( const dbPassword = legacyStartInternalDbPassword(input.dbUrl); if (input.config.realtime.enabled) { + // 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/local-container-inputs.ts b/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts index 79a0896aac..48727e8099 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts @@ -240,7 +240,7 @@ export const legacyBuildLocalDbContainerInputs = ( jwtExpiry: values.authJwtExpiry, projectId, networkId, - configImage: bootstrapConfig.postgresImage, + configImage: bootstrapConfig.postgresConfigImage, rootKey: values.rootKey, }; 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..ad8b9f0be1 --- /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", ""); + 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", ""); + 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..6dd4b045b1 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"]; @@ -138,7 +158,9 @@ export function legacyPostgresSettingsToPostgresConfig( settings: CliConfig["db"]["settings"], ): string { const defined = Object.fromEntries( - Object.entries(settings ?? {}).filter(([, value]) => value !== undefined), + Object.entries(settings ?? {}).filter( + (entry): entry is [string, string | number | boolean] => entry[1] !== undefined, + ), ); if (Object.keys(defined).length === 0) { return LEGACY_POSTGRES_CONFIG_HEADER; @@ -282,7 +304,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 +355,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 +411,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 +435,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, @@ -427,11 +450,8 @@ export function legacyBuildPostgresStartContainerSpec( } /** - * Go's `NewContainerConfig("-c", "max_worker_processes=0")` (`CreateShadowDatabase`, - * `apps/cli-go/internal/db/diff/diff.go:140`) — disables background workers in the - * shadow database. 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 `docker-entrypoint.sh postgres -D /etc/postgresql ` splice — + * disables background workers (`CreateShadowDatabase`). */ export const LEGACY_SHADOW_ENTRYPOINT_ARGS = "-c max_worker_processes=0"; @@ -527,9 +547,6 @@ export function legacyBuildShadowPostgresContainerSpec( env, entrypoint: "sh", cmd: ["-c", script], - binds: [], - autoRemove: true, - ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), ...(isPg14OrEarlier ? {} : { @@ -537,9 +554,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..cbcb7ec4cb 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 @@ -219,6 +219,16 @@ describe("legacyBuildPostgresStartContainerSpec", () => { expect(spec.env.POSTGRES_INITDB_ARGS).toBeUndefined(); }); + test("version-compare uses docker.io configImage when the pull image is a slim ghcr ref", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ + image: "ghcr.io/supabase/cli/postgres:17.6.1.167", + configImage: "supabase/postgres:17.6.1.167", + }), + ); + expect(spec.env.POSTGRES_INITDB_ARGS).toBeUndefined(); + }); + test("healthcheck matches Go's pg_isready probe", () => { const spec = legacyBuildPostgresStartContainerSpec(baseInput()); expect(spec.healthcheck).toEqual({ 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..9f32f04693 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/slim-runtime.ts @@ -0,0 +1,43 @@ +/** + * 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. + * + * 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`. + */ + +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 function legacySlimWgetHealthcheck( + 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", "wget", "--no-verbose", "--tries=1", "--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/start-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts index 19bfb04569..3050238a8c 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). + // Print this before image resolve so a flag-off cold/failed pull still + // follows the established progress order. 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..b2519c46c7 100644 --- a/apps/cli/src/legacy/shared/legacy-db-image.ts +++ b/apps/cli/src/legacy/shared/legacy-db-image.ts @@ -1,5 +1,7 @@ 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 { postgresImageForDbMajorVersion } from "../../shared/services/services.shared.ts"; +import { slimImageForCurrentPin } from "../../shared/services/slim-images.ts"; /** * Resolves the local Postgres Docker image the way `config.Load` does, @@ -11,9 +13,9 @@ 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"); -const LEGACY_PG14 = "supabase/postgres:14.1.0.89"; -const LEGACY_PG15 = "supabase/postgres:15.8.1.085"; +// 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"); /** Replace everything after the first `:` with `tag`. */ function replaceImageTag(image: string, tag: string): string { @@ -52,6 +54,16 @@ function compareSemver(a: string, b: string): number { return 0; } +export interface LegacyResolvedDbImage { + /** Pull/create reference — slim-translated when the flag is on and the pin is current. */ + readonly image: string; + /** + * Unprefixed docker.io / OrioleDB / 13–15 identity for version-compare. + * Never `ghcr.io/...` — {@link legacyPostgresImageVersionTag} splits on the first `:`. + */ + readonly configImage: string; +} + /** * Resolve the Postgres image for `majorVersion`, honoring the pinned version * written by `supabase start` to `supabase/.temp/postgres-version` (Go reads @@ -73,24 +85,14 @@ export const legacyResolveDbImage = Effect.fnUntraced(function* ( orioledbVersion.length > 0 && (majorVersion === 15 || majorVersion === 17) ) { - return versionCompare(orioledbVersion, "15.1.1.13") > 0 - ? `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 image = + versionCompare(orioledbVersion, "15.1.1.13") > 0 + ? `supabase/postgres:${orioledbVersion}-orioledb` + : `supabase/postgres:orioledb-${orioledbVersion}`; + return { image, configImage: image }; } + const currentRaw = postgresImageForDbMajorVersion(majorVersion) ?? legacyPgImageRaw(); + let appliedPin: string | undefined; if (majorVersion > 14) { const versionPath = path.join(workdir, "supabase", ".temp", "postgres-version"); const pinned = yield* fs.readFileString(versionPath).pipe( @@ -98,12 +100,21 @@ export const legacyResolveDbImage = Effect.fnUntraced(function* ( Effect.orElseSucceed(() => ""), ); if (pinned.length > 0) { - const colon = image.indexOf(":"); - const currentTag = colon >= 0 ? image.slice(colon + 1) : image; + const colon = currentRaw.indexOf(":"); + const currentTag = colon >= 0 ? currentRaw.slice(colon + 1) : currentRaw; if (versionCompare(currentTag, "15.1.0.55") >= 0) { - image = replaceImageTag(LEGACY_PG_IMAGE, pinned); + appliedPin = pinned; } } } - return image; + // PG14 has no slim build. + if (majorVersion === 14) { + return { image: currentRaw, configImage: currentRaw }; + } + const configImage = + appliedPin !== undefined ? replaceImageTag(currentRaw, appliedPin) : currentRaw; + return { + image: slimImageForCurrentPin("pg", currentRaw, appliedPin), + configImage, + }; }); 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..890a005213 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,35 @@ -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, beforeEach, vi } from "vitest"; -import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; +import { + dockerfileServiceImage, + dockerfileServiceImageRaw, +} from "../../shared/services/dockerfile-images.ts"; +import { + POSTGRES_FALLBACK_IMAGE_PG14, + POSTGRES_FALLBACK_IMAGE_PG15, + POSTGRES_FALLBACK_IMAGE_PG15_SLIM, +} from "../../shared/services/services.shared.ts"; +import { imageTag, toSlimImage } from "../../shared/services/slim-images.ts"; import { legacyResolveDbImage } from "./legacy-db-image.ts"; +const currentPostgres = dockerfileServiceImageRaw("pg"); +const currentPostgresTag = imageTag(currentPostgres) ?? ""; +const pg15SlimTag = imageTag(POSTGRES_FALLBACK_IMAGE_PG15_SLIM) ?? ""; + 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; @@ -18,12 +38,32 @@ const resolve = (workdir: string, majorVersion: number, orioledbVersion?: string }).pipe(Effect.provide(BunServices.layer)); describe("legacyResolveDbImage", () => { + beforeEach(() => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it.effect("resolves the default Postgres image per major version", () => { const dir = withTemp(); return Effect.gen(function* () { - expect(yield* resolve(dir, 14)).toBe("supabase/postgres:14.1.0.89"); - expect(yield* resolve(dir, 15)).toBe("supabase/postgres:15.8.1.085"); - expect(yield* resolve(dir, 17)).toBe(dockerfileServiceImage("pg")); + expect(yield* resolve(dir, 13)).toEqual({ + image: POSTGRES_FALLBACK_IMAGE_PG15, + configImage: POSTGRES_FALLBACK_IMAGE_PG15, + }); + expect(yield* resolve(dir, 14)).toEqual({ + image: POSTGRES_FALLBACK_IMAGE_PG14, + configImage: POSTGRES_FALLBACK_IMAGE_PG14, + }); + expect(yield* resolve(dir, 15)).toEqual({ + image: POSTGRES_FALLBACK_IMAGE_PG15, + configImage: POSTGRES_FALLBACK_IMAGE_PG15, + }); + expect(yield* resolve(dir, 17)).toEqual({ + image: dockerfileServiceImage("pg"), + configImage: currentPostgres, + }); rmSync(dir, { recursive: true, force: true }); }); }); @@ -32,10 +72,19 @@ describe("legacyResolveDbImage", () => { const dir = withTemp(); return Effect.gen(function* () { // > 15.1.1.13 → `-orioledb` - expect(yield* resolve(dir, 17, "16.0.0.1")).toBe("supabase/postgres:16.0.0.1-orioledb"); - expect(yield* resolve(dir, 15, "15.1.1.20")).toBe("supabase/postgres:15.1.1.20-orioledb"); + expect(yield* resolve(dir, 17, "16.0.0.1")).toEqual({ + image: "supabase/postgres:16.0.0.1-orioledb", + configImage: "supabase/postgres:16.0.0.1-orioledb", + }); + expect(yield* resolve(dir, 15, "15.1.1.20")).toEqual({ + image: "supabase/postgres:15.1.1.20-orioledb", + configImage: "supabase/postgres:15.1.1.20-orioledb", + }); // <= 15.1.1.13 → `orioledb-` - expect(yield* resolve(dir, 17, "15.1.0.55")).toBe("supabase/postgres:orioledb-15.1.0.55"); + expect(yield* resolve(dir, 17, "15.1.0.55")).toEqual({ + image: "supabase/postgres:orioledb-15.1.0.55", + configImage: "supabase/postgres:orioledb-15.1.0.55", + }); rmSync(dir, { recursive: true, force: true }); }); }); @@ -43,8 +92,93 @@ describe("legacyResolveDbImage", () => { it.effect("ignores orioledb_version on a non-15/17 project", () => { const dir = withTemp(); return Effect.gen(function* () { - expect(yield* resolve(dir, 14, "16.0.0.1")).toBe("supabase/postgres:14.1.0.89"); + expect(yield* resolve(dir, 14, "16.0.0.1")).toEqual({ + image: POSTGRES_FALLBACK_IMAGE_PG14, + configImage: POSTGRES_FALLBACK_IMAGE_PG14, + }); rmSync(dir, { recursive: true, force: true }); }); }); + + describe("pinned version with the slim-images flag on", () => { + it.effect("keeps a 14 fallback on docker.io, not the slim registry", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = withTemp(); + return Effect.gen(function* () { + expect(yield* resolve(dir, 14)).toEqual({ + image: POSTGRES_FALLBACK_IMAGE_PG14, + configImage: POSTGRES_FALLBACK_IMAGE_PG14, + }); + rmSync(dir, { recursive: true, force: true }); + }); + }); + + it.effect("rewrites the current PG15 fallback to the slim registry", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = withTemp(); + return Effect.gen(function* () { + expect(yield* resolve(dir, 15)).toEqual({ + image: toSlimImage("pg", POSTGRES_FALLBACK_IMAGE_PG15_SLIM), + configImage: POSTGRES_FALLBACK_IMAGE_PG15_SLIM, + }); + expect(yield* resolve(dir, 13)).toEqual({ + image: toSlimImage("pg", POSTGRES_FALLBACK_IMAGE_PG15_SLIM), + configImage: POSTGRES_FALLBACK_IMAGE_PG15_SLIM, + }); + rmSync(dir, { recursive: true, force: true }); + }); + }); + + it.effect("keeps a historical PG15 pin on docker.io", () => { + 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)).toEqual({ + image: "supabase/postgres:15.8.1.100", + configImage: "supabase/postgres:15.8.1.100", + }); + rmSync(dir, { recursive: true, force: true }); + }); + }); + + it.effect("rewrites a current PG15 pin to the slim registry", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = withTemp(); + writePin(dir, pg15SlimTag); + return Effect.gen(function* () { + expect(yield* resolve(dir, 15)).toEqual({ + image: toSlimImage("pg", POSTGRES_FALLBACK_IMAGE_PG15_SLIM), + configImage: POSTGRES_FALLBACK_IMAGE_PG15_SLIM, + }); + 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)).toEqual({ + image: "supabase/postgres:17.9.9.999", + configImage: "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)).toEqual({ + image: toSlimImage("pg", currentPostgres), + configImage: 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..48476d7b33 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,22 +17,21 @@ 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"; - -/** `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(); -} +// 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}`; /** * 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). + * (default 2 → Dockerfile image; 1 → `deno1`). The version pin is applied first, + * then `deno_version = 1` overrides to `deno1`. Historical pins stay on + * docker.io — those slim tags are not published. */ export const legacyResolveEdgeRuntimeImage = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -35,17 +39,17 @@ export const legacyResolveEdgeRuntimeImage = Effect.fnUntraced(function* ( 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); }); 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..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 @@ -2,12 +2,20 @@ 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 { + dockerfileServiceImage, + dockerfileServiceImageRaw, +} from "../../shared/services/dockerfile-images.ts"; +import { toSlimImage } from "../../shared/services/slim-images.ts"; import { legacyResolveEdgeRuntimeImage } from "./legacy-edge-runtime-image.ts"; +const currentEdgeRuntime = dockerfileServiceImageRaw("edgeruntime"); +const currentEdgeRuntimeTag = currentEdgeRuntime.split(":")[1] ?? ""; + const resolve = (workdir: string, denoVersion: number) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -53,4 +61,58 @@ 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 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-pgdelta.cache.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts index c9160d15ab..45c5140e36 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts @@ -176,7 +176,7 @@ export const legacyResolveSetupInputs = Effect.fnUntraced(function* ( orioledbVersion: string | undefined, baseline: LegacyBaselineTomlConfig, ) { - const image = yield* legacyResolveDbImage(fs, path, workdir, majorVersion, orioledbVersion); + const { image } = yield* legacyResolveDbImage(fs, path, workdir, majorVersion, orioledbVersion); const rolesPath = path.join(workdir, "supabase", "roles.sql"); const rolesSql = yield* fs .readFileString(rolesPath) 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/download.ts b/apps/cli/src/shared/functions/download.ts index c6f7c1cebe..7c88e442cc 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, @@ -1065,6 +1065,7 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( const { projectId, denoVersion, image, projectEnvValues } = edgeRuntimeImage; const functionsDir = resolve(dependencies.projectRoot, "supabase", "functions"); const hostEszipPath = resolve(eszipPath); + const cacheVolume = edgeRuntimeCacheVolume(projectId); const dockerEszipPath = posix.join(DOCKER_ESZIP_DIR, eszipFileName); const dockerOutputPath = posix.join(DOCKER_DENO_DIR, slug); @@ -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,7 +1124,7 @@ 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, { + const result = yield* runChildProcess("docker", buildFunctionsDockerRunArgs(spec), { stdout: "pipe", stderr: "pipe", onStdout: (chunk) => output.raw(chunk, output.format === "text" ? "stdout" : "stderr"), @@ -1159,7 +1158,6 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( }), ); } - // 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..4abffee598 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 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..fb2703f25a 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]> { 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..5eb8806232 --- /dev/null +++ b/apps/cli/src/shared/functions/functions.shared.unit.test.ts @@ -0,0 +1,46 @@ +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"); + }); +}); + +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); + }); +}); 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..21cdb470d0 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, @@ -106,21 +115,25 @@ export function localServiceImagesFromDockerfile( const LOCAL_SERVICE_IMAGES = localServiceImagesFromSpecs(dockerfileServiceImages); -// Mirrors Go's config image rewrite in `apps/cli-go/pkg/config/config.go`. -// Major version 13 intentionally falls through to the pg15 image there. +export const POSTGRES_FALLBACK_IMAGE_PG14 = "supabase/postgres:14.1.0.89"; +/** Flag-off PG13/15 docker.io pin. */ +export const POSTGRES_FALLBACK_IMAGE_PG15 = "supabase/postgres:15.8.1.085"; +/** Published slim PG15 pin; flag-on majors 13/15 slim-translate this, not 15.8. */ +export const POSTGRES_FALLBACK_IMAGE_PG15_SLIM = "supabase/postgres:15.14.1.167"; + export function postgresImageForDbMajorVersion(majorVersion: number): string | undefined { switch (majorVersion) { case 13: case 15: - return "supabase/postgres:15.8.1.085"; + return slimImagesEnabled() ? POSTGRES_FALLBACK_IMAGE_PG15_SLIM : POSTGRES_FALLBACK_IMAGE_PG15; case 14: - return "supabase/postgres:14.1.0.89"; + return POSTGRES_FALLBACK_IMAGE_PG14; default: return undefined; } } -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,18 +154,29 @@ 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; + // Explicit overrides are used verbatim; the caller decides slim vs docker.io. + 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 }; } + const pin = normalizeVersionTags + ? tagForServiceVersion(service.localService, version) + : version; + if (override === undefined && slim) { + return { + ...service, + image: options.slimCurrentPinOnly + ? slimImageForCurrentPin(service.alias, service.image, pin) + : slimImageForAlias(service.alias, replaceImageTag(service.image, pin)), + }; + } return { ...service, - image: replaceImageTag( - baseImage, - normalizeVersionTags ? tagForServiceVersion(service.localService, version) : version, - ), + image: replaceImageTag(baseImage, pin), }; }); } 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..ec88727e93 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, beforeEach, 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" }; @@ -7,6 +7,7 @@ import { listLocalServiceVersions, localServiceImagesFromDockerfile, parseDockerfileServiceImages, + postgresImageForDbMajorVersion, renderServicesTable, renderServicesWarning, } from "./services.shared.ts"; @@ -20,6 +21,14 @@ const runLinkedFetch = (input: Parameters[0]) Effect.runPromise(fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer))); describe("services shared", () => { + beforeEach(() => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + test("parses service images from Dockerfile FROM aliases", () => { expect( parseDockerfileServiceImages(` @@ -68,6 +77,84 @@ describe("services shared", () => { ]); }); + test("keeps the established PG13/15 fallback unless the slim flag is on", () => { + expect(postgresImageForDbMajorVersion(13)).toBe("supabase/postgres:15.8.1.085"); + expect(postgresImageForDbMajorVersion(15)).toBe("supabase/postgres:15.8.1.085"); + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect(postgresImageForDbMajorVersion(13)).toBe("supabase/postgres:15.14.1.167"); + expect(postgresImageForDbMajorVersion(15)).toBe("supabase/postgres:15.14.1.167"); + }); + + 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("normalizes historical pins before slimCurrentPinOnly", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + expect( + listLocalServiceVersions({ + slimCurrentPinOnly: true, + serviceVersions: { auth: "2.151.0" }, + }), + ).toContainEqual({ name: "supabase/gotrue", local: "v2.151.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: "", + }); + }); + + // Explicit overrides keep their registry; a serviceVersions pin still rewrites the tag. + 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..6150df9779 --- /dev/null +++ b/apps/cli/src/shared/services/slim-images.ts @@ -0,0 +1,150 @@ +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"; +} + +/** + * 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 pin's 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 slimImageRef(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 slimTagForService(service, pin) === slimTagForService(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..ad80353d25 --- /dev/null +++ b/apps/cli/src/shared/services/slim-images.unit.test.ts @@ -0,0 +1,173 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { dockerfileServiceImageRaw } from "./dockerfile-images.ts"; +import { + pinMatchesCurrentImage, + slimImageForAlias, + slimImageForCurrentPin, + slimImagesEnabled, + toSlimImage, + usesSlimImageRuntime, +} from "./slim-images.ts"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +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, dockerfileServiceImageRaw(alias)); + expect(translated.slice(0, translated.lastIndexOf(":"))).toBe(repository); + }); + + it("keeps a non-current pin instead of the catalog default", () => { + expect(toSlimImage("pg", "supabase/postgres:17.6.1.164")).toBe( + "ghcr.io/supabase/cli/postgres:17.6.1.164", + ); + expect(toSlimImage("studio", "supabase/studio:2026.08.17-sha-0c1da8f")).toBe( + "ghcr.io/supabase/cli/studio:2026.08.17-sha-0c1da8f", + ); + }); + + it("maps current docker.io pins onto the published slim tags", () => { + expect(toSlimImage("pg", dockerfileServiceImageRaw("pg"))).toBe( + "ghcr.io/supabase/cli/postgres:17.6.1.167", + ); + expect(toSlimImage("supavisor", dockerfileServiceImageRaw("supavisor"))).toBe( + "ghcr.io/supabase/cli/pooler:v2.9.12", + ); + expect(toSlimImage("realtime", dockerfileServiceImageRaw("realtime"))).toBe( + "ghcr.io/supabase/cli/realtime:v2.130.0", + ); + expect(toSlimImage("storage", dockerfileServiceImageRaw("storage"))).toBe( + "ghcr.io/supabase/cli/storage:v1.72.1", + ); + }); + + 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 = dockerfileServiceImageRaw(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); + }); +}); + +describe("slimImageForAlias", () => { + it("is a no-op while the flag is off", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", ""); + 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", ""); + 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", ""); + 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" }, diff --git a/packages/stack/src/ServiceCatalog.ts b/packages/stack/src/ServiceCatalog.ts index d04d709971..c3f0540adc 100644 --- a/packages/stack/src/ServiceCatalog.ts +++ b/packages/stack/src/ServiceCatalog.ts @@ -128,7 +128,7 @@ export const SERVICE_CATALOG = { postgres: { name: "postgres", configKey: "postgres", - defaultVersion: "17.6.1.165", + defaultVersion: "17.6.1.167", runtimeSupport: "native-preferred", artifact: { docker: { repository: "postgres" }, @@ -205,7 +205,7 @@ export const SERVICE_CATALOG = { realtime: { name: "realtime", configKey: "realtime", - defaultVersion: "v2.129.9", + defaultVersion: "v2.130.0", runtimeSupport: "docker-only", artifact: { docker: { repository: "realtime" }, @@ -217,7 +217,7 @@ export const SERVICE_CATALOG = { storage: { name: "storage", configKey: "storage", - defaultVersion: "v1.71.0", + defaultVersion: "v1.72.1", runtimeSupport: "docker-only", artifact: { docker: { repository: "storage" }, @@ -301,7 +301,7 @@ export const SERVICE_CATALOG = { pooler: { name: "pooler", configKey: "pooler", - defaultVersion: "2.9.7", + defaultVersion: "2.9.12", runtimeSupport: "docker-only", artifact: { docker: { registry: SUPABASE_GHCR_REGISTRY, repository: "supavisor" }, diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index 44f15ea635..bff157821a 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -504,6 +504,8 @@ describe("docker-backed auxiliary services", () => { expect(def.args).toContain("/tmp/supabase/storage:/var/lib/storage"); expect(def.args).toContain("54331:54331"); expect(def.dependencies).toEqual(dependencies); + expect(def.env?.ENABLE_IMAGE_TRANSFORMATION).toBe("true"); + expect(def.env?.IMAGE_TRANSFORMATION_ENABLED).toBe("true"); expect(def.healthCheck?.probe).toEqual( expect.objectContaining({ _tag: "Http", port: 54331, path: "/status" }), ); diff --git a/packages/stack/src/services/storage.ts b/packages/stack/src/services/storage.ts index e739aa962e..cf3914c720 100644 --- a/packages/stack/src/services/storage.ts +++ b/packages/stack/src/services/storage.ts @@ -72,6 +72,8 @@ export const makeStorageServiceDocker = (opts: DockerStorageOptions): ServiceDef STORAGE_S3_REGION: "local", GLOBAL_S3_BUCKET: "stub", ENABLE_IMAGE_TRANSFORMATION: String(opts.enableImageTransformation), + // storage-api prefers this key over ENABLE_IMAGE_TRANSFORMATION (v1.72+). + IMAGE_TRANSFORMATION_ENABLED: String(opts.enableImageTransformation), IMGPROXY_URL: opts.imgproxyUrl, TUS_URL_PATH: "/storage/v1/upload/resumable", S3_PROTOCOL_ENABLED: String(opts.s3ProtocolEnabled), diff --git a/packages/stack/src/versions.unit.test.ts b/packages/stack/src/versions.unit.test.ts index 086a91fa0f..33d6e929dd 100644 --- a/packages/stack/src/versions.unit.test.ts +++ b/packages/stack/src/versions.unit.test.ts @@ -114,7 +114,7 @@ describe("dockerImageForService", () => { "ghcr.io/supabase/vector:0.53.0-alpine", ); expect(dockerImageForService("pooler", DEFAULT_VERSIONS.pooler)).toBe( - "ghcr.io/supabase/supavisor:2.9.7", + `ghcr.io/supabase/supavisor:${DEFAULT_VERSIONS.pooler}`, ); });