diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index a222728c34..0e8b78b348 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -11,17 +11,21 @@ "!apps/cli/src/shared/auth/**", "!apps/cli/src/shared/compute/**", "!apps/cli/src/shared/config/**", + "!apps/cli/src/shared/feedback/**", "!apps/cli/src/shared/functions/functions-docker.ts", "!apps/cli/src/shared/functions/functions-docker.unit.test.ts", "!apps/cli/src/shared/functions/serve.ts", "!apps/cli/src/shared/functions/serve.unit.test.ts", "!apps/cli/src/shared/git/**", + "!apps/cli/src/shared/init/**", "!apps/cli/src/shared/issue/**", "!apps/cli/src/shared/runtime/**", + "!apps/cli/src/shared/services/**", "!apps/cli/src/shared/telemetry/**", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. "apps/cli/src/shared/compute/stacks/**", + "apps/cli/src/shared/feedback/database.types.ts", "!apps/cli/tests/helpers/branches-live.ts", "!apps/cli/tests/helpers/compute.ts", "!apps/cli/tests/helpers/migration-live.ts", diff --git a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts index bab4a35bc1..08ba19d556 100644 --- a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts +++ b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts @@ -14,6 +14,7 @@ import { Data, Effect, type FileSystem, Option, type Path, Schedule } from "effe import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import type { LocalServiceVersionOverrides } from "../../shared/services/services.shared.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; import { Output } from "../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { @@ -139,11 +140,12 @@ export interface StartDbSetupImages { */ function resolveDbSetupImages( serviceVersionOverrides: LocalServiceVersionOverrides, + slim: boolean, ): StartDbSetupImages { return { - realtime: resolvePinnedImage("realtime", "realtime", serviceVersionOverrides), - storage: resolvePinnedImage("storage", "storage", serviceVersionOverrides), - auth: resolvePinnedImage("gotrue", "auth", serviceVersionOverrides), + realtime: resolvePinnedImage("realtime", "realtime", serviceVersionOverrides, slim), + storage: resolvePinnedImage("storage", "storage", serviceVersionOverrides, slim), + auth: resolvePinnedImage("gotrue", "auth", serviceVersionOverrides, slim), }; } @@ -169,7 +171,7 @@ export const resolveDbSetupPrelude = (setup: { const output = yield* Output; yield* output.raw("Initialising schema...\n", "stderr"); const jwks = setup.majorVersion >= 15 && setup.realtimeEnabledForSetup ? yield* setup.jwks : ""; - const images = resolveDbSetupImages(setup.serviceVersionOverrides); + const images = resolveDbSetupImages(setup.serviceVersionOverrides, yield* slimImagesEnabled); return { jwks, images }; }); diff --git a/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts b/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts index 521c67a6fe..61c9c7120e 100644 --- a/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts +++ b/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts @@ -16,10 +16,12 @@ export function resolvePinnedImage( alias: string, localServiceName: LocalServiceVersionName, serviceVersions: LocalServiceVersionOverrides, + slim: boolean, ): string { return slimImageForCurrentPin( alias, dockerfileServiceImageRaw(alias), serviceVersions[localServiceName], + slim, ); } diff --git a/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts index 408f9adefa..bb03844234 100644 --- a/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; import { toSlimImage } from "../../shared/services/slim-images.ts"; @@ -12,67 +12,62 @@ const currentPoolerTag = currentTag("supavisor"); const currentPostgres = dockerfileServiceImageRaw("pg"); const currentPostgresTag = currentTag("pg"); -afterEach(() => { - vi.unstubAllEnvs(); -}); - describe("resolvePinnedImage", () => { it("resolves docker.io images while the slim flag is off", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", ""); - expect(resolvePinnedImage("gotrue", "auth", {})).toBe(currentAuth); - expect(resolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" })).toBe( + expect(resolvePinnedImage("gotrue", "auth", {}, false)).toBe(currentAuth); + expect(resolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" }, false)).toBe( "supabase/gotrue:v2.100.0", ); - expect(resolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" })).toBe( + expect(resolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" }, false)).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(resolvePinnedImage("gotrue", "auth", {})).toBe(toSlimImage("gotrue", currentAuth)); - expect(resolvePinnedImage("gotrue", "auth", { auth: currentAuthTag })).toBe( + expect(resolvePinnedImage("gotrue", "auth", {}, true)).toBe(toSlimImage("gotrue", currentAuth)); + expect(resolvePinnedImage("gotrue", "auth", { auth: currentAuthTag }, true)).toBe( toSlimImage("gotrue", currentAuth), ); }); it("keeps a historical pin on docker.io", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(resolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" })).toBe( + expect(resolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" }, true)).toBe( "supabase/gotrue:v2.100.0", ); - expect(resolvePinnedImage("storage", "storage", { storage: "v1.67.0" })).toBe( + expect(resolvePinnedImage("storage", "storage", { storage: "v1.67.0" }, true)).toBe( "supabase/storage-api:v1.67.0", ); - expect(resolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" })).toBe( + expect(resolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" }, true)).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(resolvePinnedImage("supavisor", "pooler", { pooler: currentPoolerTag })).toBe( + expect(resolvePinnedImage("supavisor", "pooler", { pooler: currentPoolerTag }, true)).toBe( toSlimImage("supavisor", currentPooler), ); expect( - resolvePinnedImage("supavisor", "pooler", { - pooler: currentPoolerTag.startsWith("v") - ? currentPoolerTag.slice(1) - : `v${currentPoolerTag}`, - }), + resolvePinnedImage( + "supavisor", + "pooler", + { + pooler: currentPoolerTag.startsWith("v") + ? currentPoolerTag.slice(1) + : `v${currentPoolerTag}`, + }, + true, + ), ).toBe(toSlimImage("supavisor", currentPooler)); }); it("keeps a historical postgres pin on docker.io", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", ""); - expect(resolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" })).toBe( + expect(resolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" }, false)).toBe( "supabase/postgres:17.4.1.1", ); - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - expect(resolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" })).toBe( + expect(resolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" }, true)).toBe( "supabase/postgres:17.4.1.1", ); - expect(resolvePinnedImage("pg", "postgres", { postgres: currentPostgresTag })).toBe( + expect(resolvePinnedImage("pg", "postgres", { postgres: currentPostgresTag }, true)).toBe( toSlimImage("pg", currentPostgres), ); }); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts index 5caf37116c..d3c86db653 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts @@ -48,6 +48,7 @@ import { } from "./pgdata-snapshot.ts"; import type { PgDataArchiveProblem, PgDataSnapshotUnavailable } from "./pgdata-snapshot.ts"; import { resolvePinnedImage } from "./pinned-image.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; import { createShadowDatabase, removeShadowDatabase, @@ -290,13 +291,14 @@ const resolveShadowCacheKeyInputs = ( const realtimeConsumesJwks = input.setup.majorVersion >= 15 && input.setup.config.realtime.enabled; const jwks = realtimeConsumesJwks ? yield* input.setup.jwks : ""; + const slim = yield* slimImagesEnabled; const realtimeImage = yield* resolveJobImage( - resolvePinnedImage("realtime", "realtime", overrides), + resolvePinnedImage("realtime", "realtime", overrides, slim), ); const storageImage = yield* resolveJobImage( - resolvePinnedImage("storage", "storage", overrides), + resolvePinnedImage("storage", "storage", overrides, slim), ); - const authImage = yield* resolveJobImage(resolvePinnedImage("gotrue", "auth", overrides)); + const authImage = yield* resolveJobImage(resolvePinnedImage("gotrue", "auth", overrides, slim)); if (Option.isNone(realtimeImage) || Option.isNone(storageImage) || Option.isNone(authImage)) { return Option.none(); } diff --git a/apps/cli/src/command-internal/db-image.ts b/apps/cli/src/command-internal/db-image.ts index 1747f84ace..e25b6f8695 100644 --- a/apps/cli/src/command-internal/db-image.ts +++ b/apps/cli/src/command-internal/db-image.ts @@ -1,7 +1,7 @@ import { Effect, type FileSystem, type Path } from "effect"; import { dockerfileServiceImageRaw } from "../shared/services/dockerfile-images.ts"; import { postgresImageForDbMajorVersion } from "../shared/services/services.shared.ts"; -import { slimImageForCurrentPin } from "../shared/services/slim-images.ts"; +import { slimImageForCurrentPin, slimImagesEnabled } from "../shared/services/slim-images.ts"; /** * Resolves the local Postgres Docker image for commands that run a pg_dump/shadow-DB container @@ -73,7 +73,8 @@ export const resolveDbImage = Effect.fnUntraced(function* ( : `supabase/postgres:orioledb-${orioledbVersion}`; return { image, configImage: image }; } - const currentRaw = postgresImageForDbMajorVersion(majorVersion) ?? pgImageRaw(); + const slim = yield* slimImagesEnabled; + const currentRaw = postgresImageForDbMajorVersion(majorVersion, slim) ?? pgImageRaw(); let appliedPin: string | undefined; if (majorVersion > 14) { const versionPath = path.join(workdir, "supabase", ".temp", "postgres-version"); @@ -96,7 +97,7 @@ export const resolveDbImage = Effect.fnUntraced(function* ( const configImage = appliedPin !== undefined ? replaceImageTag(currentRaw, appliedPin) : currentRaw; return { - image: slimImageForCurrentPin("pg", currentRaw, appliedPin), + image: slimImageForCurrentPin("pg", currentRaw, appliedPin, slim), configImage, }; }); diff --git a/apps/cli/src/command-internal/db-image.unit.test.ts b/apps/cli/src/command-internal/db-image.unit.test.ts index 72946b7efa..6819ae401b 100644 --- a/apps/cli/src/command-internal/db-image.unit.test.ts +++ b/apps/cli/src/command-internal/db-image.unit.test.ts @@ -61,7 +61,7 @@ describe("resolveDbImage", () => { configImage: POSTGRES_FALLBACK_IMAGE_PG15, }); expect(yield* resolve(dir, 17)).toEqual({ - image: dockerfileServiceImage("pg"), + image: dockerfileServiceImage("pg", false), configImage: currentPostgres, }); rmSync(dir, { recursive: true, force: true }); diff --git a/apps/cli/src/command-internal/edge-runtime-image.ts b/apps/cli/src/command-internal/edge-runtime-image.ts index 3f602bdc0b..2f1c29d222 100644 --- a/apps/cli/src/command-internal/edge-runtime-image.ts +++ b/apps/cli/src/command-internal/edge-runtime-image.ts @@ -4,7 +4,7 @@ import { dockerfileServiceImage, dockerfileServiceImageRaw, } from "../shared/services/dockerfile-images.ts"; -import { slimImageForCurrentPin } from "../shared/services/slim-images.ts"; +import { slimImageForCurrentPin, slimImagesEnabled } from "../shared/services/slim-images.ts"; /** * Resolves the edge-runtime Docker image: the default tag comes from the Dockerfile image, a @@ -12,9 +12,10 @@ import { slimImageForCurrentPin } from "../shared/services/slim-images.ts"; * `edge_runtime.deno_version = 1` selects the legacy `deno1` image instead. */ -// 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 edgeRuntimeDockerfileImage = () => dockerfileServiceImage("edgeruntime"); +// Read per run, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is observed. +export const edgeRuntimeDockerfileImage = Effect.map(slimImagesEnabled, (slim) => + dockerfileServiceImage("edgeruntime", slim), +); // 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. @@ -44,5 +45,10 @@ export const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( if (pinned === DENO1_EDGE_RUNTIME_VERSION) { return EDGE_RUNTIME_DENO1_IMAGE; } - return slimImageForCurrentPin("edgeruntime", raw, pinned.length > 0 ? pinned : undefined); + return slimImageForCurrentPin( + "edgeruntime", + raw, + pinned.length > 0 ? pinned : undefined, + yield* slimImagesEnabled, + ); }); diff --git a/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts b/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts index 458531b686..e791bb63e7 100644 --- a/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts +++ b/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts @@ -10,7 +10,7 @@ import { dockerfileServiceImage, dockerfileServiceImageRaw, } from "../shared/services/dockerfile-images.ts"; -import { toSlimImage } from "../shared/services/slim-images.ts"; +import { slimImagesEnabled, toSlimImage } from "../shared/services/slim-images.ts"; import { resolveEdgeRuntimeImage } from "./edge-runtime-image.ts"; const currentEdgeRuntime = dockerfileServiceImageRaw("edgeruntime"); @@ -26,10 +26,10 @@ const resolve = (workdir: string, denoVersion: number) => describe("resolveEdgeRuntimeImage", () => { it.effect("returns the edge-runtime image from the Dockerfile when nothing is pinned", () => { const dir = mkdtempSync(join(tmpdir(), "edge-img-")); - return resolve(dir, 2).pipe( - Effect.tap((image) => + return Effect.zip(resolve(dir, 2), slimImagesEnabled).pipe( + Effect.tap(([image, slim]) => Effect.sync(() => { - expect(image).toBe(dockerfileServiceImage("edgeruntime")); + expect(image).toBe(dockerfileServiceImage("edgeruntime", slim)); rmSync(dir, { recursive: true, force: true }); }), ), diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index 9cf0c5dbae..f0cb2c5f88 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -2076,7 +2076,7 @@ describe("db diff", () => { yield* dbDiff(flags({ usePgAdmin: Option.some(true) })); expect(s.differCalls).toHaveLength(1); const call = s.differCalls[0] as DockerRunOpts; - expect(call.image).toBe(dockerfileServiceImage("differ")); + expect(call.image).toBe(dockerfileServiceImage("differ", false)); expect(call.image).toBe("supabase/pgadmin-schema-diff:cli-0.0.5"); expect(call.cmd).toEqual(["--json-diff", PGADMIN_SOURCE_URL, PGADMIN_TARGET_URL]); expect(call.env).toEqual({}); diff --git a/apps/cli/src/commands/db/diff/pgadmin-diff.ts b/apps/cli/src/commands/db/diff/pgadmin-diff.ts index 4fc272ca5c..35b3e23dde 100644 --- a/apps/cli/src/commands/db/diff/pgadmin-diff.ts +++ b/apps/cli/src/commands/db/diff/pgadmin-diff.ts @@ -22,7 +22,8 @@ import { trimGoSpace } from "../shared/go-string.ts"; import { INTERNAL_SCHEMAS } from "../../../command-internal/pg-dump.env.ts"; import { DbDiffPgAdminError } from "./diff.errors.ts"; -const DIFFER_IMAGE = dockerfileServiceImage("differ"); +// `differ` has no slim build, so the slim flag never applies to it. +const DIFFER_IMAGE = dockerfileServiceImage("differ", false); /** * Only the front of the buffer is trimmed, not every occurrence — a real pgAdmin4 diff --git a/apps/cli/src/commands/functions/download/download.integration.test.ts b/apps/cli/src/commands/functions/download/download.integration.test.ts index 0c00600613..7c9b82c61b 100644 --- a/apps/cli/src/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/commands/functions/download/download.integration.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { BunCrypto } from "@effect/platform-bun"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { Config, ConfigProvider, @@ -596,7 +597,7 @@ describe("functions download", () => { // The unbundle tail is always the last 6 args regardless of whether // `--add-host` (Linux-only) was inserted before it. expect(runCommand?.args.slice(-6)).toEqual([ - `public.ecr.aws/${dockerfileServiceImage("edgeruntime")}`, + `public.ecr.aws/${dockerfileServiceImage("edgeruntime", yield* slimImagesEnabled)}`, "unbundle", "--eszip", "/root/eszips/output_hello-world.eszip", @@ -2044,7 +2045,7 @@ describe("functions download", () => { const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args.slice(-6)[0]).toBe( - `ghcr.io/${dockerfileServiceImage("edgeruntime")}`, + `ghcr.io/${dockerfileServiceImage("edgeruntime", yield* slimImagesEnabled)}`, ); expect( child.spawned.filter( @@ -2123,7 +2124,7 @@ describe("functions download", () => { ).toHaveLength(2); const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args.slice(-6)[0]).toBe( - `ghcr.io/${dockerfileServiceImage("edgeruntime")}`, + `ghcr.io/${dockerfileServiceImage("edgeruntime", yield* slimImagesEnabled)}`, ); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.integration.test.ts index 7c176ed579..275063814f 100644 --- a/apps/cli/src/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.integration.test.ts @@ -50,6 +50,7 @@ import { } from "../../../shared/runtime/process-control.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { getRegistryImageUrl } from "../../../command-internal/docker-registry.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { @@ -1031,7 +1032,7 @@ describe("functions serve integration", () => { } expect(dockerRun.args).toContain( - yield* getRegistryImageUrl(dockerfileServiceImage("edgeruntime")), + yield* getRegistryImageUrl(dockerfileServiceImage("edgeruntime", yield* slimImagesEnabled)), ); expect(dockerRun.args.join(" ")).not.toContain(multilineValue); expect(dockerRun.args.join(" ")).not.toContain("EOF_ENV_0"); diff --git a/apps/cli/src/commands/gen/types/types.e2e.test.ts b/apps/cli/src/commands/gen/types/types.e2e.test.ts index 0eec083ffe..505f1b65fd 100644 --- a/apps/cli/src/commands/gen/types/types.e2e.test.ts +++ b/apps/cli/src/commands/gen/types/types.e2e.test.ts @@ -9,6 +9,7 @@ import { withTempHome, } from "../../../../tests/helpers/cli.ts"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { localDbContainerId } from "../../../command-internal/docker-ids.ts"; import { RESOLVE_BUDGET_MS, @@ -21,7 +22,6 @@ const TYPEGEN_LANGS: ReadonlyArray = languages .map((language) => language.name); type TypegenLang = string; -const LOCAL_POSTGRES_IMAGE = dockerfileServiceImage("pg"); const LOCAL_POSTGRES_TIMEOUT_MS = 120_000; const TYPEGEN_TIMEOUT_MS = 90_000; // Image resolution runs inside the test body, so its timeout must add on top of the @@ -214,8 +214,9 @@ const startLocalPostgres = Effect.fnUntraced(function* (input: { }) { const containerName = localDbContainerId(input.projectId); const imageDeadline = resolveDeadline(LOCAL_IMAGE_BUDGET_MS); + const localPostgresImage = dockerfileServiceImage("pg", yield* slimImagesEnabled); const postgresImage = yield* Effect.tryPromise({ - try: () => ensureImage(LOCAL_POSTGRES_IMAGE, imageDeadline - RESOLVE_BUDGET_MS), + try: () => ensureImage(localPostgresImage, imageDeadline - RESOLVE_BUDGET_MS), catch: (cause) => new TypegenE2eSetupError({ message: "failed to ensure Docker image", cause }), }); diff --git a/apps/cli/src/commands/migration/squash/squash.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.integration.test.ts index 31f99f4aa5..74ea9d1ea6 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -36,6 +36,7 @@ import { mockTty, } from "../../../../tests/helpers/mocks.ts"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { getRegistryImageUrl } from "../../../command-internal/docker-registry.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { @@ -750,7 +751,9 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* seedHappyPathMigrations(tmp.current); yield* migrationSquash(flags()); - const expectedImage = yield* getRegistryImageUrl(dockerfileServiceImage("pg")); + const expectedImage = yield* getRegistryImageUrl( + dockerfileServiceImage("pg", yield* slimImagesEnabled), + ); expect(s.dumpCalls).toHaveLength(3); for (const call of s.dumpCalls) { expect(call.env["PGPORT"]).toBe("54320"); diff --git a/apps/cli/src/commands/services/services.handler.ts b/apps/cli/src/commands/services/services.handler.ts index dbbb151ed6..4cee002ec2 100644 --- a/apps/cli/src/commands/services/services.handler.ts +++ b/apps/cli/src/commands/services/services.handler.ts @@ -33,6 +33,7 @@ import { renderServicesWarning, type ServiceVersionRow, } from "../../shared/services/services.shared.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; import type { ServicesFlags } from "./services.command.ts"; import { ServicesEnvNotSupportedError } from "./services.errors.ts"; import { stackServiceVersions } from "./services-local-stack.ts"; @@ -176,6 +177,7 @@ export const services = Effect.fn("services")(function* (_flags: ServicesFlags) imageOverrides["edge-runtime"] = edgeRuntimeImage; } const localImageOptions = { + slim: yield* slimImagesEnabled, imageOverrides, normalizeVersionTags: false, serviceVersions, diff --git a/apps/cli/src/commands/services/services.integration.test.ts b/apps/cli/src/commands/services/services.integration.test.ts index c22e86f41a..10970a06e0 100644 --- a/apps/cli/src/commands/services/services.integration.test.ts +++ b/apps/cli/src/commands/services/services.integration.test.ts @@ -33,6 +33,7 @@ import { import { mockTelemetryStateTracked, useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; import { postgresImageForDbMajorVersion } from "../../shared/services/services.shared.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.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"; @@ -188,8 +189,8 @@ const writeTempFile = Effect.fnUntraced(function* (workdir: string, name: string yield* fs.writeFileString(path.join(tempDir, name), content); }); -function postgresVersionForDbMajorVersion(majorVersion: number): string { - const image = postgresImageForDbMajorVersion(majorVersion); +function postgresVersionForDbMajorVersion(majorVersion: number, slim: boolean): string { + const image = postgresImageForDbMajorVersion(majorVersion, slim); if (image === undefined) { throw new Error(`Missing Postgres image for db major ${majorVersion}.`); } @@ -316,7 +317,7 @@ describe("services", () => { expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", - local: postgresVersionForDbMajorVersion(15), + local: postgresVersionForDbMajorVersion(15, yield* slimImagesEnabled), }), ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), @@ -417,7 +418,7 @@ describe("services", () => { expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", - local: postgresVersionForDbMajorVersion(15), + local: postgresVersionForDbMajorVersion(15, yield* slimImagesEnabled), }), ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), @@ -444,7 +445,7 @@ major_version = 15 expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", - local: postgresVersionForDbMajorVersion(15), + local: postgresVersionForDbMajorVersion(15, yield* slimImagesEnabled), }), ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), @@ -562,7 +563,7 @@ major_version = 15 expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", - local: postgresVersionForDbMajorVersion(15), + local: postgresVersionForDbMajorVersion(15, yield* slimImagesEnabled), remote: "17.6.1.200", }), ); diff --git a/apps/cli/src/commands/start/services/gotrue.service.ts b/apps/cli/src/commands/start/services/gotrue.service.ts index 8de0ff43f1..dce09c4b3a 100644 --- a/apps/cli/src/commands/start/services/gotrue.service.ts +++ b/apps/cli/src/commands/start/services/gotrue.service.ts @@ -553,6 +553,8 @@ export function buildGotrueEnv(input: BuildGotrueEnvInput): Record { - vi.unstubAllEnvs(); -}); - // Every field not asserted by a specific subtest below reflects the // default config's own values. const baseEnvInput: BuildGotrueEnvInput = { @@ -674,6 +670,7 @@ describe("buildGotrueEnv", () => { describe("buildGotrueContainerSpec", () => { test("assembles the full container spec, deriving dbHost/dbPassword from projectId/dbUrl", () => { const spec = buildGotrueContainerSpec({ + slim: false, image: "supabase/gotrue:v2.180.0", projectId: "proj", networkId: "supabase_network_proj", @@ -710,8 +707,8 @@ describe("buildGotrueContainerSpec", () => { }); test("uses BusyBox wget flags on a slim auth image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildGotrueContainerSpec({ + slim: true, image: "ghcr.io/supabase/cli/auth:v2.196.0", projectId: "proj", networkId: "supabase_network_proj", diff --git a/apps/cli/src/commands/start/services/logflare.service.ts b/apps/cli/src/commands/start/services/logflare.service.ts index 8ae024e704..a4b39aa6a7 100644 --- a/apps/cli/src/commands/start/services/logflare.service.ts +++ b/apps/cli/src/commands/start/services/logflare.service.ts @@ -49,6 +49,8 @@ const LOGFLARE_ENTRYPOINT_SCRIPT = "EOF\n"; export interface LogflareContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** * The already-resolved `config.analytics.image`. Not part of the decoded * `@supabase/config` schema; resolution is the caller's responsibility. @@ -111,7 +113,7 @@ export function buildLogflareContainerSpec( }; const binds: Array = []; - const slim = usesSlimImageRuntime(input.image); + const slim = usesSlimImageRuntime(input.image, input.slim); if (input.backend === "bigquery") { const hostJwtPath = path.join(input.workdir, input.gcpJwtPath); diff --git a/apps/cli/src/commands/start/services/logflare.service.unit.test.ts b/apps/cli/src/commands/start/services/logflare.service.unit.test.ts index dda73d1fad..2555d56798 100644 --- a/apps/cli/src/commands/start/services/logflare.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/logflare.service.unit.test.ts @@ -2,15 +2,12 @@ import { BunPath } from "@effect/platform-bun"; import { Effect, Path } from "effect"; import { it } from "@effect/vitest"; -import { afterEach, describe, expect, vi } from "vitest"; +import { describe, expect } from "vitest"; import { buildLogflareContainerSpec, type LogflareContainerSpecInput } from "./logflare.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - const base: LogflareContainerSpecInput = { + slim: false, image: "supabase/logflare:1.0.0", projectId: "proj", networkId: "supabase_network_proj", @@ -172,10 +169,10 @@ describe("buildLogflareContainerSpec", () => { () => { return Effect.gen(function* () { const path = yield* Path.Path; - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildLogflareContainerSpec( { ...base, + slim: true, image: "ghcr.io/supabase/cli/analytics:v1.50.6", backend: "bigquery", gcpProjectId: "my-project", @@ -195,15 +192,15 @@ describe("buildLogflareContainerSpec", () => { it.effect("overrides the entrypoint and uses wget on a slim analytics image", () => { return Effect.gen(function* () { const path = yield* Path.Path; - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const slim = buildLogflareContainerSpec( { ...base, + slim: true, image: "ghcr.io/supabase/cli/analytics:v1.50.6", }, path, ); - const dockerIo = buildLogflareContainerSpec(base, path); + const dockerIo = buildLogflareContainerSpec({ ...base, slim: true }, path); expect(slim.entrypoint).toBe(dockerIo.entrypoint); expect(slim.cmd).toEqual(dockerIo.cmd); expect(slim.healthcheck?.test).toEqual([ diff --git a/apps/cli/src/commands/start/services/realtime.service.ts b/apps/cli/src/commands/start/services/realtime.service.ts index 8dafa26358..ea7ca439c4 100644 --- a/apps/cli/src/commands/start/services/realtime.service.ts +++ b/apps/cli/src/commands/start/services/realtime.service.ts @@ -16,6 +16,8 @@ import { usesSlimImageRuntime } from "../../../shared/services/slim-images.ts"; import { startInternalDbPassword } from "../../../command-internal/db-bootstrap/internal-db-connection.ts"; export interface RealtimeContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** The sanitized project id. */ readonly projectId: string; /** `container.HostConfig.NetworkMode`'s target; resolved once per `start` run, not per-container. */ @@ -51,7 +53,7 @@ export function buildRealtimeContainerSpec(input: RealtimeContainerSpecInput): S env, binds: [], exposedPorts: [{ containerPort: "4000" }], - healthcheck: usesSlimImageRuntime(input.image) + healthcheck: usesSlimImageRuntime(input.image, input.slim) ? slimWgetHealthcheck("http://127.0.0.1:4000/api/ping", { header: `Host:${REALTIME_TENANT_ID}`, }) diff --git a/apps/cli/src/commands/start/services/realtime.service.unit.test.ts b/apps/cli/src/commands/start/services/realtime.service.unit.test.ts index f6b5812a48..4feba183ed 100644 --- a/apps/cli/src/commands/start/services/realtime.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/realtime.service.unit.test.ts @@ -1,13 +1,10 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { buildRealtimeContainerSpec, type RealtimeContainerSpecInput } from "./realtime.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - describe("buildRealtimeContainerSpec", () => { const input: RealtimeContainerSpecInput = { + slim: false, projectId: "proj", networkId: "supabase_network_proj", image: "supabase/realtime:v2", @@ -68,9 +65,9 @@ describe("buildRealtimeContainerSpec", () => { }); test("uses wget for the healthcheck on a slim realtime image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildRealtimeContainerSpec({ ...input, + slim: true, image: "ghcr.io/supabase/cli/realtime:v2.130.0", }); expect(spec.healthcheck?.test).toEqual([ diff --git a/apps/cli/src/commands/start/services/storage.service.ts b/apps/cli/src/commands/start/services/storage.service.ts index 8a4e6ccf88..b38fe1edd5 100644 --- a/apps/cli/src/commands/start/services/storage.service.ts +++ b/apps/cli/src/commands/start/services/storage.service.ts @@ -135,6 +135,8 @@ export function buildStorageEnv(input: StorageEnvInput): Record } export interface StorageContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** The sanitized project id. */ readonly projectId: string; /** `container.HostConfig.NetworkMode`'s target; resolved once per `start` run, not per-container. */ @@ -190,7 +192,7 @@ export function buildStorageContainerSpec(input: StorageContainerSpecInput): Sta env, binds: [`${containerName}:${STORAGE_DOCKER_PATH}`], // IPv4 loopback: localhost can resolve to IPv6 on GitPod and miss the listener. - healthcheck: usesSlimImageRuntime(input.image) + healthcheck: usesSlimImageRuntime(input.image, input.slim) ? slimWgetHealthcheck("http://127.0.0.1:5000/status") : { test: [ diff --git a/apps/cli/src/commands/start/services/storage.service.unit.test.ts b/apps/cli/src/commands/start/services/storage.service.unit.test.ts index d2ab97f05a..7cd8bd0d35 100644 --- a/apps/cli/src/commands/start/services/storage.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/storage.service.unit.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { appendStorageVectorEnv, @@ -8,10 +8,6 @@ import { type StorageEnvInput, } from "./storage.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - const baseEnvInput: StorageEnvInput = { targetMigration: "", anonKey: "anon-key", @@ -178,6 +174,7 @@ describe("appendStorageVectorEnv", () => { describe("buildStorageContainerSpec", () => { const input: StorageContainerSpecInput = { + slim: false, projectId: "proj", networkId: "supabase_network_proj", image: "supabase/storage-api:v1", @@ -230,9 +227,9 @@ describe("buildStorageContainerSpec", () => { }); test("uses BusyBox wget flags on a slim storage image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildStorageContainerSpec({ ...input, + slim: true, image: "ghcr.io/supabase/cli/storage:v1.72.1", }); expect(spec.healthcheck?.test).toEqual([ diff --git a/apps/cli/src/commands/start/services/supavisor.service.ts b/apps/cli/src/commands/start/services/supavisor.service.ts index dbf7f7d3f9..db2c8b317f 100644 --- a/apps/cli/src/commands/start/services/supavisor.service.ts +++ b/apps/cli/src/commands/start/services/supavisor.service.ts @@ -56,6 +56,8 @@ export function buildSupavisorStartCmd(): ReadonlyArray { } export interface SupavisorContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** The already-resolved `config.db.pooler.image`; resolution is the caller's responsibility. */ readonly image: string; /** The project id, used to derive this container's own name via {@link serviceContainerName}. */ @@ -130,7 +132,7 @@ export function buildSupavisorContainerSpec( ], ports: [{ hostPort: String(input.port), containerPort: dockerPort }], // Slim pooler ships wget, not curl. - healthcheck: usesSlimImageRuntime(input.image) + healthcheck: usesSlimImageRuntime(input.image, input.slim) ? slimWgetHealthcheck("http://127.0.0.1:4000/api/health") : { test: [ diff --git a/apps/cli/src/commands/start/services/supavisor.service.unit.test.ts b/apps/cli/src/commands/start/services/supavisor.service.unit.test.ts index 26839a34ee..b6052078cd 100644 --- a/apps/cli/src/commands/start/services/supavisor.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/supavisor.service.unit.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { buildSupavisorContainerSpec, @@ -6,11 +6,8 @@ import { type SupavisorContainerSpecInput, } from "./supavisor.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - const base: SupavisorContainerSpecInput = { + slim: false, image: "supabase/supavisor:2.0.0", projectId: "proj", networkId: "supabase_network_proj", @@ -133,9 +130,9 @@ describe("buildSupavisorContainerSpec", () => { }); test("uses wget for the healthcheck on a slim pooler image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildSupavisorContainerSpec({ ...base, + slim: true, image: "ghcr.io/supabase/cli/pooler:v2.9.12", }); expect(spec.healthcheck?.test).toEqual([ diff --git a/apps/cli/src/commands/start/services/vector.service.ts b/apps/cli/src/commands/start/services/vector.service.ts index c82690c315..fac0f51757 100644 --- a/apps/cli/src/commands/start/services/vector.service.ts +++ b/apps/cli/src/commands/start/services/vector.service.ts @@ -246,6 +246,8 @@ export function buildVectorEntrypointScript( } export interface VectorContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** `config.analytics.vector_image`, already resolved/pulled by the caller. */ readonly image: string; /** `serviceContainerName("vector", projectId)`, also used as the `vector.yaml` template's `vectorId` field. */ @@ -276,7 +278,7 @@ export interface VectorContainerSpecInput { /** Builds Vector's {@link StartContainerSpec}. */ export function buildVectorContainerSpec(input: VectorContainerSpecInput): StartContainerSpec { - const slim = usesSlimImageRuntime(input.image); + const slim = usesSlimImageRuntime(input.image, input.slim); const vectorYaml = renderStartVectorYaml({ apiKey: input.apiKey, vectorId: input.containerName, diff --git a/apps/cli/src/commands/start/services/vector.service.unit.test.ts b/apps/cli/src/commands/start/services/vector.service.unit.test.ts index 0f991a8666..c82f2bae8b 100644 --- a/apps/cli/src/commands/start/services/vector.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/vector.service.unit.test.ts @@ -1,5 +1,4 @@ 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,10 +14,6 @@ import { type VectorDockerSocketPlan, } 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 }, @@ -223,6 +218,7 @@ describe("buildVectorEntrypointScript", () => { }); const base: VectorContainerSpecInput = { + slim: false, image: "supabase/vector:0.28.1", containerName: "supabase_vector_proj", networkId: "supabase_network_proj", @@ -287,9 +283,9 @@ describe("buildVectorContainerSpec", () => { }); test("slim image waits on Logflare with BusyBox wget flags", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildVectorContainerSpec({ ...base, + slim: true, image: "ghcr.io/supabase/cli/vector:0.53.0", }); expect(spec.entrypoint).toBe("sh"); diff --git a/apps/cli/src/commands/start/start.gates.ts b/apps/cli/src/commands/start/start.gates.ts index eda523cb2e..3c5b21aecd 100644 --- a/apps/cli/src/commands/start/start.gates.ts +++ b/apps/cli/src/commands/start/start.gates.ts @@ -212,6 +212,7 @@ export interface StartImagePlanEntry { */ export function resolveStartImagePlan( gates: StartGates, + slim: boolean, serviceVersions: LocalServiceVersionOverrides = {}, ): ReadonlyArray { const plan: Array = []; @@ -223,8 +224,8 @@ export function resolveStartImagePlan( const localServiceName = START_SERVICE_TO_LOCAL_VERSION_NAME[entry.service]; const image = localServiceName === undefined - ? dockerfileServiceImage(alias) - : resolvePinnedImage(alias, localServiceName, serviceVersions); + ? dockerfileServiceImage(alias, slim) + : resolvePinnedImage(alias, localServiceName, serviceVersions, slim); plan.push({ service: entry.service, image }); } return plan; diff --git a/apps/cli/src/commands/start/start.handler.ts b/apps/cli/src/commands/start/start.handler.ts index 3dd0155ed9..c307619cdf 100644 --- a/apps/cli/src/commands/start/start.handler.ts +++ b/apps/cli/src/commands/start/start.handler.ts @@ -134,6 +134,7 @@ import { START_WAITING_FOR_HEALTH_CHECKS_MESSAGE, } from "./start.format.ts"; import { resolveStartGates, resolveStartImagePlan } from "./start.gates.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; import { isUnhealthyStartError, rollbackStart, @@ -755,7 +756,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); // 7. Resolve every image that will actually be pulled before any container is created. - const imagePlan = resolveStartImagePlan(gates, serviceVersionOverrides); + const slim = yield* slimImagesEnabled; + const imagePlan = resolveStartImagePlan(gates, slim, serviceVersionOverrides); // Edge Runtime doesn't go through `resolveStartImagePlan` (see `start.gates.ts`'s header), // so its default image is resolved independently, pre-pulled when enabled and not excluded. const edgeRuntimeDefaultImage = gates.edgeRuntime @@ -1099,6 +1101,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { spec: buildLogflareContainerSpec( { image, + slim, projectId, networkId, port: analyticsPort, @@ -1131,6 +1134,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { return { spec: buildVectorContainerSpec({ image, + slim, containerName: vectorContainerName, networkId, apiKey: ANALYTICS_API_KEY, @@ -1189,6 +1193,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { return { spec: buildGotrueContainerSpec({ image, + slim, projectId, networkId, dbUrl: values.dbUrl, @@ -1221,6 +1226,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectId, networkId, image, + slim, ipVersion: realtimeIpVersion, maxHeaderLength: realtimeMaxHeaderLength, dbUrl: values.dbUrl, @@ -1249,6 +1255,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectId, networkId, image, + slim, targetMigration: storageTargetMigration, fileSizeLimit: storageFileSizeLimit, s3Region: values.storageS3Region, @@ -1334,6 +1341,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { return { spec: buildSupavisorContainerSpec({ image, + slim, projectId, networkId, port: poolerPort, diff --git a/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts b/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts index 179a7e3ae1..c7429d744b 100644 --- a/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts +++ b/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts @@ -17,6 +17,7 @@ import { import { getRegistryImageUrl } from "../../command-internal/docker-registry.ts"; import { SERVICE_CATALOG } from "../../command-internal/service-catalog.ts"; import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; class StartE2eSetupError extends Data.TaggedError("StartE2eSetupError")<{ readonly message: string; @@ -286,7 +287,9 @@ describe("supabase start (e2e)", () => { const mailpitContainer = serviceContainerName("inbucket", projectId); // The exact tag `start` resolves for Mailpit, so its already-cached check finds this // broken build without reaching a registry. - const mailpitImage = yield* getRegistryImageUrl(dockerfileServiceImage("mailpit")); + const mailpitImage = yield* getRegistryImageUrl( + dockerfileServiceImage("mailpit", yield* slimImagesEnabled), + ); const init = yield* runSupabaseEffect(["init"], { cwd: projectDir, diff --git a/apps/cli/src/commands/start/start.services.unit.test.ts b/apps/cli/src/commands/start/start.services.unit.test.ts index 73f6921233..8443a5eecd 100644 --- a/apps/cli/src/commands/start/start.services.unit.test.ts +++ b/apps/cli/src/commands/start/start.services.unit.test.ts @@ -1,6 +1,6 @@ import { CliConfigSchema, type CliConfig } from "@supabase/config"; import { Schema } from "effect"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; import type { LocalServiceVersionOverrides } from "../../shared/services/services.shared.ts"; @@ -216,10 +216,6 @@ describe("START_SERVICES enabledGate cross-check against start.gates.ts", () => }); describe("resolveStartImagePlan under SUPABASE_USE_SLIM_IMAGES", () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - const allGatesOpen: StartGates = { kong: true, gotrue: true, @@ -236,26 +232,29 @@ describe("resolveStartImagePlan under SUPABASE_USE_SLIM_IMAGES", () => { edgeRuntime: true, }; - const imageFor = (service: string, serviceVersions: LocalServiceVersionOverrides = {}) => - resolveStartImagePlan(allGatesOpen, serviceVersions).find((entry) => entry.service === service) - ?.image; + const imageFor = ( + service: string, + slim: boolean, + serviceVersions: LocalServiceVersionOverrides = {}, + ) => + resolveStartImagePlan(allGatesOpen, slim, 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"); + expect(imageFor("gotrue", false)).toBe(currentGotrue); + expect(imageFor("vector", false)).toBe(currentVector); + expect(imageFor("supavisor", false, { 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( + expect(imageFor("gotrue", true)).toBe(toSlimImage("gotrue", currentGotrue)); + expect(imageFor("logflare", true)).toBe(toSlimImage("logflare", currentLogflare)); + expect(imageFor("vector", true)).toBe(toSlimImage("vector", currentVector)); + expect(imageFor("supavisor", true, { 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"); + expect(imageFor("supavisor", true, { pooler: "2.0.0" })).toBe("supabase/supavisor:2.0.0"); + expect(imageFor("kong", true)).toBe("library/kong:2.8.1"); }); }); diff --git a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts index a16778e794..ed0c55277b 100644 --- a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts +++ b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Fiber } from "effect"; +import { Deferred, Effect, Exit, Fiber, Schema } from "effect"; import { feedbackClientLayer } from "./feedback-client.layer.ts"; import type { FeedbackSubmission } from "./feedback-client.service.ts"; import { FeedbackClient } from "./feedback-client.service.ts"; @@ -39,11 +39,16 @@ function recordingFetch( ) { const requests: Array<{ request: Request; bodyText: string }> = []; const fetch: typeof globalThis.fetch = Object.assign( - async (input: string | URL | Request, init?: RequestInit) => { + (input: string | URL | Request, init?: RequestInit): Promise => { const request = input instanceof Request ? new Request(input, init) : new Request(String(input), init); - requests.push({ request, bodyText: await request.clone().text() }); - return respond(request); + return request + .clone() + .text() + .then((bodyText) => { + requests.push({ request, bodyText }); + return respond(request); + }); }, { preconnect: () => Promise.resolve() }, ); @@ -57,6 +62,10 @@ function jsonResponse(body: unknown, status = 200) { }); } +const decodeJsonBody = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + function layerWith(transport: ReturnType) { return feedbackClientLayer({ environment: TEST_ENV, fetch: transport.fetch }); } @@ -75,7 +84,7 @@ describe("feedbackClientLayer", () => { expect(request.method).toBe("POST"); expect(request.url).toBe(`${TEST_ENV.url}/rest/v1/rpc/submit_interfaces_feedback`); expect(request.headers.get("apikey")).toBe(TEST_ENV.key); - expect(JSON.parse(bodyText)).toEqual({ + expect(yield* decodeJsonBody(bodyText)).toEqual({ feedback: "port conflicts when running two stacks", user_agent: "SupabaseCLI/9.9.9", project_ref: PROJECT_REF, @@ -107,12 +116,12 @@ describe("feedbackClientLayer", () => { }, }); - const body = JSON.parse(transport.requests[0]!.bodyText); + const body = yield* decodeJsonBody(transport.requests[0]!.bodyText); // The RPC's `project_ref` parameter defaults to null server-side; the // CLI simply leaves it (and `user_id`) out of the call. expect(body).not.toHaveProperty("project_ref"); expect(body).not.toHaveProperty("user_id"); - expect(body.metadata).toEqual({ + expect(body["metadata"]).toEqual({ cli_version: "9.9.9", source: "cli", os: "linux", @@ -172,20 +181,18 @@ describe("feedbackClientLayer", () => { // insert commit after the command was cancelled. The fake fetch behaves // like a real one: it settles only when its abort signal fires. let capturedSignal: AbortSignal | undefined; - const inFlight = Promise.withResolvers(); + const inFlight = Deferred.makeUnsafe(); const transport = recordingFetch((request) => { capturedSignal = request.signal; - inFlight.resolve(); - return new Promise((_, reject) => { - request.signal.addEventListener("abort", () => reject(request.signal.reason)); - }); + Deferred.doneUnsafe(inFlight, Exit.void); + return Effect.runPromise(Effect.never, { signal: request.signal }); }); return Effect.gen(function* () { const client = yield* FeedbackClient; const fiber = yield* client .submit(SUBMISSION) .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => inFlight.promise); + yield* Deferred.await(inFlight); yield* Fiber.interrupt(fiber); expect(capturedSignal?.aborted).toBe(true); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 53104dbaaf..2282b7b6ef 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -34,6 +34,7 @@ import { invalidFunctionSlugDetail, validateFunctionSlugMessage, } from "./functions.shared.ts"; +import { slimImagesEnabled } from "../services/slim-images.ts"; import { ConflictingFunctionDeployFlagsError, FunctionDeployCancelledError, @@ -1457,7 +1458,7 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( const outputPath = join(outputDir, "output.eszip"); // `edgeRuntimeImage` applies the tag verbatim — 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 rawImage = edgeRuntimeImage(edgeRuntimeVersion, yield* slimImagesEnabled); const binds = yield* buildDockerBinds(projectId, functionsDir, outputDir, config, { bitbucketCloneDirDefined, onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 82aaf569f5..46f8bce592 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -35,6 +35,7 @@ import { invalidFunctionSlugDetail, validateFunctionSlugMessage, } from "./functions.shared.ts"; +import { slimImagesEnabled } from "../services/slim-images.ts"; import { ConflictingFunctionDownloadFlagsError, FunctionDownloadNotFoundError, @@ -899,7 +900,7 @@ const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( // `edgeRuntimeImage` applies the tag verbatim; a `.temp/edge-runtime-version` // pin flows through unmodified. Registry mapping + pull-with-retry happens // per-container, right before `ensureDockerNetwork` (see the caller). - rawImage: edgeRuntimeImage(edgeRuntimeVersion), + rawImage: edgeRuntimeImage(edgeRuntimeVersion, yield* slimImagesEnabled), projectEnvValues: context.projectEnvValues, }; }); diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 0c6ebabb3c..2f691ac4ff 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -32,11 +32,11 @@ export const DENO1_EDGE_RUNTIME_VERSION = "v1.68.4"; * is substituted verbatim into the (possibly slim-rewritten) default image, * with no `v` prefix synthesized. */ -export function edgeRuntimeImage(tag: string): string { +export function edgeRuntimeImage(tag: string, slim: boolean): string { if (tag === DENO1_EDGE_RUNTIME_VERSION) { return `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`; } - return slimImageForCurrentPin("edgeruntime", dockerfileServiceImageRaw("edgeruntime"), tag); + return slimImageForCurrentPin("edgeruntime", dockerfileServiceImageRaw("edgeruntime"), tag, slim); } /** diff --git a/apps/cli/src/shared/functions/functions.shared.unit.test.ts b/apps/cli/src/shared/functions/functions.shared.unit.test.ts index 5eb8806232..64e5a3af49 100644 --- a/apps/cli/src/shared/functions/functions.shared.unit.test.ts +++ b/apps/cli/src/shared/functions/functions.shared.unit.test.ts @@ -17,22 +17,19 @@ afterEach(() => { 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( + expect(edgeRuntimeImage(DENO1_EDGE_RUNTIME_VERSION, true)).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( + expect(edgeRuntimeImage(currentEdgeRuntimeTag, true)).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"); + expect(edgeRuntimeImage("v1.73.0", true)).toBe("supabase/edge-runtime:v1.73.0"); }); }); 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 39ea81a62e..b83d3ae5e0 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 @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; +import { Effect } from "effect"; import { describe, expect, test } from "vitest"; import { START_KONG_YML_TEMPLATE } from "../../commands/start/templates/kong.yml.ts"; @@ -274,7 +275,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(edgeRuntimeDockerfileImage()); + const runtimeImage = await ensureImage(await Effect.runPromise(edgeRuntimeDockerfileImage)); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-offline-e2e-")); const container = `supabase-serve-offline-e2e-${process.pid.toString()}`; try { @@ -336,7 +337,7 @@ describe("functions serve runtime template (offline)", () => { "returns canonical JWT auth failures", { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, async () => { - const runtimeImage = await ensureImage(edgeRuntimeDockerfileImage()); + const runtimeImage = await ensureImage(await Effect.runPromise(edgeRuntimeDockerfileImage)); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-auth-e2e-")); const container = `supabase-serve-auth-e2e-${process.pid.toString()}`; try { @@ -431,8 +432,8 @@ describe("functions serve runtime template (offline)", () => { async () => { const imageDeadline = resolveDeadline(); const [runtimeImage, kongImage] = await Promise.all([ - ensureImage(edgeRuntimeDockerfileImage(), imageDeadline), - ensureImage(dockerfileServiceImage("kong"), imageDeadline), + ensureImage(await Effect.runPromise(edgeRuntimeDockerfileImage), imageDeadline), + ensureImage(dockerfileServiceImage("kong", false), imageDeadline), ]); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-kong-e2e-")); const network = `supabase-serve-kong-e2e-${process.pid.toString()}`; diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 78d0ecf405..e55ba8ddec 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -89,6 +89,7 @@ import { } from "./functions-docker.ts"; import { loadFunctionsCliConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; import { edgeRuntimeImage, resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; +import { slimImagesEnabled } from "../services/slim-images.ts"; import { DockerLogsStreamError, EdgeRuntimeContainerCrashedError, @@ -2031,7 +2032,7 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { // `docker pull` on cold cache instead of immediately — left open since // fixing it risks `start`'s shared, more critical bring-up path. const image = yield* resolveFunctionsDockerImage( - edgeRuntimeImage(edgeRuntimeVersion), + edgeRuntimeImage(edgeRuntimeVersion, yield* slimImagesEnabled), resolved.projectEnvValues, ); diff --git a/apps/cli/src/shared/init/project-init.modes.integration.test.ts b/apps/cli/src/shared/init/project-init.modes.integration.test.ts index 58ac00eb09..b89df561fd 100644 --- a/apps/cli/src/shared/init/project-init.modes.integration.test.ts +++ b/apps/cli/src/shared/init/project-init.modes.integration.test.ts @@ -1,17 +1,21 @@ -import { mkdirSync, mkdtempSync, rmSync, statSync } 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, Layer } from "effect"; +import { Effect, FileSystem, Layer, Path } from "effect"; import { mockOutput, mockStdin, mockTty } from "../../../tests/helpers/mocks.ts"; import { initProject } from "./project-init.ts"; -function makeTempProjectDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-init-modes-")); -} +const makeTempProjectDir = Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.makeTempDirectoryScoped({ prefix: "supabase-init-modes-" }), +); + +// Pin the process umask to 0 to prove the modes below are pinned explicitly, +// not incidental to Node's own umask-masked defaults (which coincide under +// the common 022). +const zeroUmask = Effect.acquireRelease( + Effect.sync(() => process.umask(0)), + (prevUmask) => Effect.sync(() => process.umask(prevUmask)), +); function runInit(cwd: string) { const out = mockOutput({ format: "text", interactive: false }); @@ -29,54 +33,39 @@ function runInit(cwd: string) { }).pipe(Effect.provide(layer)); } -// Pin the process umask to 0 to prove the modes below are pinned explicitly, -// not incidental to Node's own umask-masked defaults (which coincide under -// the common 022). +const fileMode = Effect.fnUntraced(function* (pathname: string) { + const fs = yield* FileSystem.FileSystem; + return (yield* fs.stat(pathname)).mode & 0o777; +}); + describe("initProject file modes (Go parity: 0755 dirs, 0644 files)", () => { - it.live("pins the supabase dir and config.toml to Go's exact modes", () => { - const cwd = makeTempProjectDir(); - const prevUmask = process.umask(0); + it.live("pins the supabase dir and config.toml to Go's exact modes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const cwd = yield* makeTempProjectDir; + yield* zeroUmask; - return runInit(cwd).pipe( - Effect.andThen( - Effect.sync(() => { - const supabaseDir = join(cwd, "supabase"); - const configTomlPath = join(supabaseDir, "config.toml"); + yield* runInit(cwd); - expect(statSync(supabaseDir).mode & 0o777).toBe(0o755); - expect(statSync(configTomlPath).mode & 0o777).toBe(0o644); - }), - ), - Effect.ensuring( - Effect.sync(() => { - process.umask(prevUmask); - rmSync(cwd, { recursive: true, force: true }); - }), - ), - ); - }); + const supabaseDir = path.join(cwd, "supabase"); + expect(yield* fileMode(supabaseDir)).toBe(0o755); + expect(yield* fileMode(path.join(supabaseDir, "config.toml"))).toBe(0o644); + }).pipe(Effect.provide(BunServices.layer)), + ); it.live( "pins a freshly created supabase/.gitignore to Go's exact file mode inside a git repo", - () => { - const cwd = makeTempProjectDir(); - mkdirSync(join(cwd, ".git")); - const prevUmask = process.umask(0); + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempProjectDir; + yield* fs.makeDirectory(path.join(cwd, ".git")); + yield* zeroUmask; + + yield* runInit(cwd); - return runInit(cwd).pipe( - Effect.andThen( - Effect.sync(() => { - const gitignorePath = join(cwd, "supabase", ".gitignore"); - expect(statSync(gitignorePath).mode & 0o777).toBe(0o644); - }), - ), - Effect.ensuring( - Effect.sync(() => { - process.umask(prevUmask); - rmSync(cwd, { recursive: true, force: true }); - }), - ), - ); - }, + expect(yield* fileMode(path.join(cwd, "supabase", ".gitignore"))).toBe(0o644); + }).pipe(Effect.provide(BunServices.layer)), ); }); diff --git a/apps/cli/src/shared/init/project-init.templates.unit.test.ts b/apps/cli/src/shared/init/project-init.templates.unit.test.ts index 6dbb7073d6..5e802505bd 100644 --- a/apps/cli/src/shared/init/project-init.templates.unit.test.ts +++ b/apps/cli/src/shared/init/project-init.templates.unit.test.ts @@ -1,7 +1,6 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import { applyConfigEdits, type ConfigEdit } from "@supabase/config/internal"; import { INIT_GITIGNORE_TEMPLATE, @@ -11,18 +10,20 @@ import { renderCliConfigTemplate, } from "./project-init.templates.ts"; -const here = dirname(fileURLToPath(import.meta.url)); -// Vendored copies of the Go CLI's init-template scaffold files. Dotted file -// names are de-dotted so git/tooling don't interpret the fixtures themselves. -const goTemplatesFixtureDir = join(here, "testdata/go-templates"); - function normalizeNewlines(text: string): string { return text.replace(/\r\n/g, "\n"); } -function readVendoredTemplate(name: string): string { - return normalizeNewlines(readFileSync(join(goTemplatesFixtureDir, name), "utf8")); -} +// Vendored copies of the Go CLI's init-template scaffold files. Dotted file +// names are de-dotted so git/tooling don't interpret the fixtures themselves. +const readVendoredTemplate = Effect.fnUntraced(function* (name: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const contents = yield* fs.readFileString( + path.join(import.meta.dirname, "testdata/go-templates", name), + ); + return normalizeNewlines(contents); +}); // Go's config.toml scaffold renders through text/template (`config.Eject`), so // an action wrapping a backtick raw string — {{ `{{ .Code }}` }} in the @@ -34,16 +35,16 @@ function resolveGoTemplateEscapes(template: string): string { } // Emulates what Go's config.Eject writes to disk for a fresh `supabase init` project. -function renderExpectedGoEject(): string { - return ( - resolveGoTemplateEscapes(readVendoredTemplate("config.toml")) +const renderExpectedGoEject = readVendoredTemplate("config.toml").pipe( + Effect.map((template) => + resolveGoTemplateEscapes(template) .replace("{{ .ProjectId }}", "demo-project") .replace("{{ .Experimental.OrioleDBVersion }}", "15.1.0.150") // supabase init always opts new projects into pg-delta; the Go template // renders this from a flag only set on the init path. - .replace("{{ .Experimental.PgDeltaInitEnabled }}", "true") - ); -} + .replace("{{ .Experimental.PgDeltaInitEnabled }}", "true"), + ), +); // The Go scaffold still describes `auto_expose_new_tables` as unset-means- // revoked and deprecated; the native template documents unset-means-exposed @@ -57,29 +58,37 @@ const NATIVE_AUTO_EXPOSE_COMMENT = `# without explicit GRANTs, matching the clou # instead. Left unset, a fresh project falls back to \`true\`. # auto_expose_new_tables = true`; -function renderExpectedNativeEject(): string { - return renderExpectedGoEject() - .replace( - '# content_path = "./templates/password_changed_notification.html"', - '# content_path = "./supabase/templates/password_changed_notification.html"', - ) - .replace(GO_AUTO_EXPOSE_COMMENT, NATIVE_AUTO_EXPOSE_COMMENT); -} +const renderExpectedNativeEject = renderExpectedGoEject.pipe( + Effect.map((eject) => + eject + .replace( + '# content_path = "./templates/password_changed_notification.html"', + '# content_path = "./supabase/templates/password_changed_notification.html"', + ) + .replace(GO_AUTO_EXPOSE_COMMENT, NATIVE_AUTO_EXPOSE_COMMENT), + ), +); describe("project init templates", () => { - it("renders config.toml with the native notification content_path base", () => { - expect(normalizeNewlines(renderCliConfigTemplate("demo-project", true))).toBe( - renderExpectedNativeEject(), - ); - }); + it.effect("renders config.toml with the native notification content_path base", () => + Effect.gen(function* () { + expect(normalizeNewlines(renderCliConfigTemplate("demo-project", true))).toBe( + yield* renderExpectedNativeEject, + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("models every template action in the Go scaffold, so parity cannot silently drift", () => { - // Anything beyond the GoTrue OTP placeholder means the Go template gained - // a construct this suite doesn't emulate; update `resolveGoTemplateEscapes` - // to match before shipping. - const unresolvedActions = renderExpectedGoEject().match(/\{\{[^}]*\}\}/g) ?? []; - expect(new Set(unresolvedActions)).toEqual(new Set(["{{ .Code }}"])); - }); + it.effect( + "models every template action in the Go scaffold, so parity cannot silently drift", + () => + Effect.gen(function* () { + // Anything beyond the GoTrue OTP placeholder means the Go template gained + // a construct this suite doesn't emulate; update `resolveGoTemplateEscapes` + // to match before shipping. + const unresolvedActions = (yield* renderExpectedGoEject).match(/\{\{[^}]*\}\}/g) ?? []; + expect(new Set(unresolvedActions)).toEqual(new Set(["{{ .Code }}"])); + }).pipe(Effect.provide(BunServices.layer)), + ); it("renders the SMS and MFA phone OTP templates as GoTrue templates, not raw Go escapes", () => { const rendered = renderCliConfigTemplate("demo-project", false); @@ -112,21 +121,33 @@ describe("project init templates", () => { expect(rendered).not.toMatch(/^port = 54327$/m); }); - it("matches the Go .gitignore scaffold", () => { - expect(INIT_GITIGNORE_TEMPLATE).toBe(readVendoredTemplate("gitignore")); - }); + it.effect("matches the Go .gitignore scaffold", () => + Effect.gen(function* () { + expect(INIT_GITIGNORE_TEMPLATE).toBe(yield* readVendoredTemplate("gitignore")); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("matches the Go VS Code extensions scaffold", () => { - expect(VSCODE_EXTENSIONS_TEMPLATE).toBe(readVendoredTemplate("vscode-extensions.json.golden")); - }); + it.effect("matches the Go VS Code extensions scaffold", () => + Effect.gen(function* () { + expect(VSCODE_EXTENSIONS_TEMPLATE).toBe( + yield* readVendoredTemplate("vscode-extensions.json.golden"), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("matches the Go VS Code settings scaffold", () => { - expect(VSCODE_SETTINGS_TEMPLATE).toBe(readVendoredTemplate("vscode-settings.json.golden")); - }); + it.effect("matches the Go VS Code settings scaffold", () => + Effect.gen(function* () { + expect(VSCODE_SETTINGS_TEMPLATE).toBe( + yield* readVendoredTemplate("vscode-settings.json.golden"), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("matches the Go IntelliJ scaffold", () => { - expect(INTELLIJ_DENO_TEMPLATE).toBe(readVendoredTemplate("idea-deno.xml")); - }); + it.effect("matches the Go IntelliJ scaffold", () => + Effect.gen(function* () { + expect(INTELLIJ_DENO_TEMPLATE).toBe(yield* readVendoredTemplate("idea-deno.xml")); + }).pipe(Effect.provide(BunServices.layer)), + ); }); // `applyConfigEdits` must edit the scaffold exactly as intended and nothing diff --git a/apps/cli/src/shared/init/project-init.ts b/apps/cli/src/shared/init/project-init.ts index a5d8e82b30..33675d242d 100644 --- a/apps/cli/src/shared/init/project-init.ts +++ b/apps/cli/src/shared/init/project-init.ts @@ -106,6 +106,8 @@ const decodeJsonObject = Schema.decodeUnknownEffect( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); +const encodePrettyJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 })); + // Parses a settings file through a Schema boundary so malformed JSON surfaces // as a typed `InitParseSettingsError` (never a fiber defect) and a // non-object document is rejected. @@ -147,9 +149,9 @@ const INIT_DIR_MODE = 0o755; function writeJsonFile(pathname: string, contents: Record) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(pathname, `${JSON.stringify(contents, null, 2)}\n`, { - mode: INIT_FILE_MODE, - }); + // `contents` was just decoded from JSON, so encoding it back cannot fail. + const json = yield* encodePrettyJson(contents).pipe(Effect.orDie); + yield* fs.writeFileString(pathname, `${json}\n`, { mode: INIT_FILE_MODE }); }); } diff --git a/apps/cli/src/shared/services/dockerfile-go-sync.unit.test.ts b/apps/cli/src/shared/services/dockerfile-go-sync.unit.test.ts index d8d058c146..5471886e14 100644 --- a/apps/cli/src/shared/services/dockerfile-go-sync.unit.test.ts +++ b/apps/cli/src/shared/services/dockerfile-go-sync.unit.test.ts @@ -1,18 +1,20 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, test } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import serviceImagesDockerfile from "./Dockerfile" with { type: "text" }; // The Go tree still `go:embed`s its own copy for a dependency that hasn't been removed // yet; this keeps the two copies in sync until apps/cli-go is deleted, at which point // this test should be deleted alongside it. -const GO_DOCKERFILE_PATH = fileURLToPath( - new URL("../../../../cli-go/pkg/config/templates/Dockerfile", import.meta.url), -); - describe("Go Dockerfile sync guard", () => { - test("keeps the Go tree's embedded Dockerfile byte-identical to the TS-owned copy", () => { - const goDockerfile = readFileSync(GO_DOCKERFILE_PATH, "utf8"); - expect(goDockerfile).toBe(serviceImagesDockerfile); - }); + it.effect("keeps the Go tree's embedded Dockerfile byte-identical to the TS-owned copy", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const goDockerfile = yield* fs.readFileString( + path.resolve(import.meta.dirname, "../../../../cli-go/pkg/config/templates/Dockerfile"), + ); + expect(goDockerfile).toBe(serviceImagesDockerfile); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/services/dockerfile-images.ts b/apps/cli/src/shared/services/dockerfile-images.ts index 70ca17a9b2..377f78a034 100644 --- a/apps/cli/src/shared/services/dockerfile-images.ts +++ b/apps/cli/src/shared/services/dockerfile-images.ts @@ -25,6 +25,6 @@ export function dockerfileServiceImageRaw(alias: string): string { * 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)); +export function dockerfileServiceImage(alias: string, slim: boolean): string { + return slimImageForAlias(alias, dockerfileServiceImageRaw(alias), slim); } diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 1496757576..fb86318605 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -14,7 +14,7 @@ import { parseDockerfileServiceImages, type DockerfileImageSpec, } from "./dockerfile-images.ts"; -import { slimImageForAlias, slimImageForCurrentPin, slimImagesEnabled } from "./slim-images.ts"; +import { slimImageForAlias, slimImageForCurrentPin } from "./slim-images.ts"; export { parseDockerfileServiceImages } from "./dockerfile-images.ts"; @@ -36,6 +36,8 @@ export type LocalServiceVersionOverrides = Partial>; export interface LocalServiceImageOptions { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; readonly imageOverrides?: LocalServiceImageOverrides; readonly normalizeVersionTags?: boolean; readonly serviceVersions?: LocalServiceVersionOverrides; @@ -120,11 +122,14 @@ 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 { +export function postgresImageForDbMajorVersion( + majorVersion: number, + slim: boolean, +): string | undefined { switch (majorVersion) { case 13: case 15: - return slimImagesEnabled() ? POSTGRES_FALLBACK_IMAGE_PG15_SLIM : POSTGRES_FALLBACK_IMAGE_PG15; + return slim ? POSTGRES_FALLBACK_IMAGE_PG15_SLIM : POSTGRES_FALLBACK_IMAGE_PG15; case 14: return POSTGRES_FALLBACK_IMAGE_PG14; default: @@ -166,14 +171,14 @@ export function isUsableServiceVersionTag( } function localServiceImagesForOptions( - options: LocalServiceImageOptions = {}, + options: LocalServiceImageOptions, ): ReadonlyArray { const normalizeVersionTags = options.normalizeVersionTags ?? true; - const slim = slimImagesEnabled(); + const slim = options.slim; return LOCAL_SERVICE_IMAGES.map((service) => { // 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 baseImage = override ?? slimImageForAlias(service.alias, service.image, slim); const version = options.serviceVersions?.[service.localService]; if (version === undefined || version.trim().length === 0) { return baseImage === service.image ? service : { ...service, image: baseImage }; @@ -185,8 +190,8 @@ function localServiceImagesForOptions( return { ...service, image: options.slimCurrentPinOnly - ? slimImageForCurrentPin(service.alias, service.image, pin) - : slimImageForAlias(service.alias, replaceImageTag(service.image, pin)), + ? slimImageForCurrentPin(service.alias, service.image, pin, slim) + : slimImageForAlias(service.alias, replaceImageTag(service.image, pin), slim), }; } return { @@ -364,7 +369,7 @@ const fetchPostgrestVersion = Effect.fnUntraced(function* ( const normalized = version?.trim().split(/\s+/)[0]; if (normalized === undefined || normalized.length === 0) { - return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "postgrest" })); + return yield* new ServiceVersionNotFoundError({ service: "postgrest" }); } return tagForServiceVersion("postgrest", normalized); @@ -379,7 +384,7 @@ const fetchAuthVersion = Effect.fnUntraced(function* ( const version = stringField(body, "version")?.trim(); if (version === undefined || version.length === 0) { - return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "auth" })); + return yield* new ServiceVersionNotFoundError({ service: "auth" }); } return version; @@ -392,15 +397,15 @@ const fetchStorageVersion = Effect.fnUntraced(function* ( ) { const version = (yield* fetchText(client, `${baseUrl}/storage/v1/version`, accessKey)).trim(); if (version.length === 0 || version === "0.0.0") { - return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "storage" })); + return yield* new ServiceVersionNotFoundError({ service: "storage" }); } return tagForServiceVersion("storage", version); }); -const fetchOptionalVersion = ( +const fetchOptionalVersion = ( service: OptionalRemoteServiceName, - effect: Effect.Effect, + effect: Effect.Effect, ) => effect.pipe( Effect.exit, @@ -420,14 +425,14 @@ const makeConfiguredApiClient = Effect.fnUntraced(function* (input: ServiceFetch }); export function listLocalServiceVersions( - options: LocalServiceImageOptions = {}, + options: LocalServiceImageOptions, ): ReadonlyArray { return localServiceImagesForOptions(options).map((service) => toServiceVersionRow(service)); } export function mergeRemoteServiceVersions( remote: Partial>, - options: LocalServiceImageOptions = {}, + options: LocalServiceImageOptions, ): ReadonlyArray { return localServiceImagesForOptions(options).map((service) => toServiceVersionRow(service, remote), 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 c22fb49b29..2471e2423c 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 { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, it, test } from "@effect/vitest"; import { Effect, Redacted } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import serviceImagesDockerfile from "./Dockerfile" with { type: "text" }; @@ -18,17 +18,15 @@ const PROJECT_REF = "abcdefghijklmnopqrst"; // `fetchLinkedServiceVersions` reads the ambient HttpClient from context instead // of self-provisioning one, so each invocation needs a concrete transport. const runLinkedFetch = (input: Parameters[0]) => - Effect.runPromise(fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer))); + fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer)); -describe("services shared", () => { - beforeEach(() => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); +const serve = (options: Parameters[0]) => + Effect.acquireRelease( + Effect.try(() => Bun.serve(options)), + (server) => Effect.promise(() => server.stop(true)), + ); +describe("services shared", () => { test("parses service images from Dockerfile FROM aliases", () => { expect( parseDockerfileServiceImages(` @@ -51,7 +49,7 @@ describe("services shared", () => { }); test("derives local service versions from the Dockerfile manifest", () => { - const rows = listLocalServiceVersions(); + const rows = listLocalServiceVersions({ slim: false }); const dockerfileImages = localServiceImagesFromDockerfile(serviceImagesDockerfile); const expectedRows = dockerfileImages.map((service) => { const tagSeparator = service.image.lastIndexOf(":"); @@ -78,16 +76,14 @@ 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"); + expect(postgresImageForDbMajorVersion(13, false)).toBe("supabase/postgres:15.8.1.085"); + expect(postgresImageForDbMajorVersion(15, false)).toBe("supabase/postgres:15.8.1.085"); + expect(postgresImageForDbMajorVersion(13, true)).toBe("supabase/postgres:15.14.1.167"); + expect(postgresImageForDbMajorVersion(15, true)).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([ + expect(listLocalServiceVersions({ slim: true }).map((row) => row.name)).toEqual([ "ghcr.io/supabase/cli/postgres", "ghcr.io/supabase/cli/auth", "ghcr.io/supabase/cli/postgrest", @@ -102,9 +98,9 @@ describe("services shared", () => { }); test("keeps historical pins on docker.io when slimCurrentPinOnly is set", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); expect( listLocalServiceVersions({ + slim: true, slimCurrentPinOnly: true, serviceVersions: { pooler: "2.0.0", analytics: "1.4.0" }, }), @@ -117,9 +113,9 @@ describe("services shared", () => { }); test("normalizes historical pins before slimCurrentPinOnly", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); expect( listLocalServiceVersions({ + slim: true, slimCurrentPinOnly: true, serviceVersions: { auth: "2.151.0" }, }), @@ -127,8 +123,9 @@ describe("services shared", () => { }); 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({ + expect( + listLocalServiceVersions({ slim: true, serviceVersions: { storage: "v1.70.3" } }), + ).toContainEqual({ name: "ghcr.io/supabase/cli/storage", local: "v1.70.3", remote: "", @@ -137,8 +134,8 @@ describe("services shared", () => { // 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({ + slim: true, imageOverrides: { postgres: "supabase/postgres:15.8.1.085", "edge-runtime": "supabase/edge-runtime:v1.68.4", @@ -158,6 +155,7 @@ describe("services shared", () => { test("can preserve raw local service version overrides", () => { expect( listLocalServiceVersions({ + slim: false, normalizeVersionTags: false, serviceVersions: { auth: "2.151.0", @@ -171,58 +169,58 @@ describe("services shared", () => { ); }); - test("returns postgres only when no service-role key is available", async () => { - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return Response.json({ - id: PROJECT_REF, - ref: PROJECT_REF, - organization_id: "org-id", - organization_slug: "org", - name: "Linked Project", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.6.1.200", - postgres_engine: "17", - release_channel: "ga", - }, - }); - } - - if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return Response.json([ - { - name: "anon", - id: "publishable-id", - type: "publishable", - api_key: "publishable-key", - description: null, - }, - ]); - } - - if ( - url.pathname === "/auth/v1/health" || - url.pathname === "/rest/v1/" || - url.pathname === "/storage/v1/version" - ) { - throw new Error( - `tenant endpoint should not be called without a service-role key: ${url.pathname}`, - ); - } - - return new Response("not found", { status: 404 }); - }, - }); + it.live("returns postgres only when no service-role key is available", () => + Effect.gen(function* () { + const server = yield* serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return Response.json({ + id: PROJECT_REF, + ref: PROJECT_REF, + organization_id: "org-id", + organization_slug: "org", + name: "Linked Project", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.6.1.200", + postgres_engine: "17", + release_channel: "ga", + }, + }); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return Response.json([ + { + name: "anon", + id: "publishable-id", + type: "publishable", + api_key: "publishable-key", + description: null, + }, + ]); + } + + if ( + url.pathname === "/auth/v1/health" || + url.pathname === "/rest/v1/" || + url.pathname === "/storage/v1/version" + ) { + throw new Error( + `tenant endpoint should not be called without a service-role key: ${url.pathname}`, + ); + } - try { - const result = await runLinkedFetch({ + return new Response("not found", { status: 404 }); + }, + }); + + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: PROJECT_REF, @@ -232,45 +230,43 @@ describe("services shared", () => { }); expect(result).toEqual({ postgres: "17.6.1.200" }); - } finally { - await server.stop(true); - } - }); + }), + ); + + it.live("returns no linked versions when project api keys cannot be loaded", () => + Effect.gen(function* () { + const server = yield* serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return new Response("boom", { status: 500 }); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return Response.json({ + id: PROJECT_REF, + ref: PROJECT_REF, + organization_id: "org-id", + organization_slug: "org", + name: "Linked Project", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.6.1.200", + postgres_engine: "17", + release_channel: "ga", + }, + }); + } - test("returns no linked versions when project api keys cannot be loaded", async () => { - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return new Response("boom", { status: 500 }); - } - - if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return Response.json({ - id: PROJECT_REF, - ref: PROJECT_REF, - organization_id: "org-id", - organization_slug: "org", - name: "Linked Project", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.6.1.200", - postgres_engine: "17", - release_channel: "ga", - }, - }); - } - - return new Response("not found", { status: 404 }); - }, - }); + return new Response("not found", { status: 404 }); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: PROJECT_REF, @@ -280,51 +276,49 @@ describe("services shared", () => { }); expect(result).toEqual({}); - } finally { - await server.stop(true); - } - }); + }), + ); + + it.live("still returns tenant service versions when project version lookup fails", () => + Effect.gen(function* () { + const server = yield* serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return new Response("boom", { status: 500 }); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return Response.json([ + { + name: "service_role", + id: "key-id", + type: "secret", + api_key: "service-role-key", + description: null, + secret_jwt_template: { role: "service_role" }, + }, + ]); + } + + if (url.pathname === "/auth/v1/health") { + return Response.json({ version: "v2.190.0" }); + } + + if (url.pathname === "/rest/v1/") { + return Response.json({ info: { version: "14.13" } }); + } + + if (url.pathname === "/storage/v1/version") { + return new Response("1.61.0"); + } - test("still returns tenant service versions when project version lookup fails", async () => { - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return new Response("boom", { status: 500 }); - } - - if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return Response.json([ - { - name: "service_role", - id: "key-id", - type: "secret", - api_key: "service-role-key", - description: null, - secret_jwt_template: { role: "service_role" }, - }, - ]); - } - - if (url.pathname === "/auth/v1/health") { - return Response.json({ version: "v2.190.0" }); - } - - if (url.pathname === "/rest/v1/") { - return Response.json({ info: { version: "14.13" } }); - } - - if (url.pathname === "/storage/v1/version") { - return new Response("1.61.0"); - } - - return new Response("not found", { status: 404 }); - }, - }); + return new Response("not found", { status: 404 }); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: PROJECT_REF, @@ -338,51 +332,49 @@ describe("services shared", () => { postgrest: "v14.13", storage: "v1.61.0", }); - } finally { - await server.stop(true); - } - }); + }), + ); + + it.live("keeps an already-prefixed tenant version, including uppercase V", () => + Effect.gen(function* () { + const server = yield* serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return new Response("boom", { status: 500 }); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return Response.json([ + { + name: "service_role", + id: "key-id", + type: "secret", + api_key: "service-role-key", + description: null, + secret_jwt_template: { role: "service_role" }, + }, + ]); + } + + if (url.pathname === "/auth/v1/health") { + return Response.json({ version: "v2.190.0" }); + } + + if (url.pathname === "/rest/v1/") { + return Response.json({ info: { version: "V14.13" } }); + } + + if (url.pathname === "/storage/v1/version") { + return new Response("v1.77.1-versions"); + } - test("keeps an already-prefixed tenant version, including uppercase V", async () => { - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return new Response("boom", { status: 500 }); - } - - if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return Response.json([ - { - name: "service_role", - id: "key-id", - type: "secret", - api_key: "service-role-key", - description: null, - secret_jwt_template: { role: "service_role" }, - }, - ]); - } - - if (url.pathname === "/auth/v1/health") { - return Response.json({ version: "v2.190.0" }); - } - - if (url.pathname === "/rest/v1/") { - return Response.json({ info: { version: "V14.13" } }); - } - - if (url.pathname === "/storage/v1/version") { - return new Response("v1.77.1-versions"); - } - - return new Response("not found", { status: 404 }); - }, - }); + return new Response("not found", { status: 404 }); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: PROJECT_REF, @@ -396,62 +388,62 @@ describe("services shared", () => { postgrest: "V14.13", storage: "v1.77.1-versions", }); - } finally { - await server.stop(true); - } - }); + }), + ); - test("falls back to empty linked versions when the linked fetch fails", async () => { - const result = await runLinkedFetch({ - apiUrl: "http://127.0.0.1:1", - projectHost: "supabase.co", - projectRef: PROJECT_REF, - accessToken: ACCESS_TOKEN, - userAgent: "supabase", - }); + it.live("falls back to empty linked versions when the linked fetch fails", () => + Effect.gen(function* () { + const result = yield* runLinkedFetch({ + apiUrl: "http://127.0.0.1:1", + projectHost: "supabase.co", + projectRef: PROJECT_REF, + accessToken: ACCESS_TOKEN, + userAgent: "supabase", + }); - expect(result).toEqual({}); - }); + expect(result).toEqual({}); + }), + ); + + it.live("authenticates tenant probes with apikey only for sb_ keys", () => + Effect.gen(function* () { + const authHeaders: Record = {}; + const server = yield* serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return Response.json([ + { + name: "service_role", + id: "key-id", + type: "secret", + api_key: "sb_secret_servicerolekey", + description: null, + secret_jwt_template: { role: "service_role" }, + }, + ]); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return new Response("boom", { status: 500 }); + } + + if (url.pathname === "/auth/v1/health") { + authHeaders.apikey = request.headers.get("apikey"); + authHeaders.authorization = request.headers.get("authorization"); + return Response.json({ version: "v2.190.0" }); + } + + if (url.pathname === "/rest/v1/" || url.pathname === "/storage/v1/version") { + return new Response("not found", { status: 404 }); + } - test("authenticates tenant probes with apikey only for sb_ keys", async () => { - const authHeaders: Record = {}; - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return Response.json([ - { - name: "service_role", - id: "key-id", - type: "secret", - api_key: "sb_secret_servicerolekey", - description: null, - secret_jwt_template: { role: "service_role" }, - }, - ]); - } - - if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return new Response("boom", { status: 500 }); - } - - if (url.pathname === "/auth/v1/health") { - authHeaders.apikey = request.headers.get("apikey"); - authHeaders.authorization = request.headers.get("authorization"); - return Response.json({ version: "v2.190.0" }); - } - - if (url.pathname === "/rest/v1/" || url.pathname === "/storage/v1/version") { return new Response("not found", { status: 404 }); - } - - return new Response("not found", { status: 404 }); - }, - }); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: PROJECT_REF, @@ -463,21 +455,19 @@ describe("services shared", () => { expect(result).toEqual({ auth: "v2.190.0" }); expect(authHeaders.apikey).toBe("sb_secret_servicerolekey"); expect(authHeaders.authorization).toBeNull(); - } finally { - await server.stop(true); - } - }); - - test("skips remote lookups for a malformed project ref", async () => { - const server = Bun.serve({ - port: 0, - fetch() { - throw new Error("no request should be made for a malformed project ref"); - }, - }); + }), + ); + + it.live("skips remote lookups for a malformed project ref", () => + Effect.gen(function* () { + const server = yield* serve({ + port: 0, + fetch() { + throw new Error("no request should be made for a malformed project ref"); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: "not-a-valid-ref", @@ -486,13 +476,11 @@ describe("services shared", () => { }); expect(result).toEqual({}); - } finally { - await server.stop(true); - } - }); + }), + ); test("renders the local services table with expected headers and rows", () => { - const rows = listLocalServiceVersions(); + const rows = listLocalServiceVersions({ slim: false }); const table = renderServicesTable(rows); expect(table).toContain("SERVICE IMAGE"); diff --git a/apps/cli/src/shared/services/slim-images.ts b/apps/cli/src/shared/services/slim-images.ts index af0635fc11..e5fb6b0521 100644 --- a/apps/cli/src/shared/services/slim-images.ts +++ b/apps/cli/src/shared/services/slim-images.ts @@ -1,3 +1,5 @@ +import { Config, ConfigProvider, Effect, Option } from "effect"; + const SLIM_IMAGES_ENV = "SUPABASE_USE_SLIM_IMAGES"; const SLIM_IMAGE_PREFIX = "ghcr.io/supabase/cli/"; @@ -41,11 +43,13 @@ const V_PREFIXED_SERVICES: ReadonlySet = new Set([ "pooler", ]); -/** Reads the ambient slim-image flag for callers without an explicit project value. */ -export function slimImagesEnabled(): boolean { - const value = process.env[SLIM_IMAGES_ENV]; - return value === "true" || value === "1"; -} +/** + * Reads the ambient slim-image flag for callers without an explicit project value: always the + * process environment, never the active `ConfigProvider`. That lookup cannot fail. + */ +export const slimImagesEnabled = Effect.suspend(() => + Config.option(Config.string(SLIM_IMAGES_ENV)).parse(ConfigProvider.fromEnv()), +).pipe(Effect.map(Option.exists((value) => value === "true" || value === "1")), Effect.orDie); /** * Catalog-normalized slim tag under `ghcr.io/supabase/cli/`. The @@ -111,8 +115,8 @@ export function toSlimImage(alias: string, image: string): string { } /** `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 slimImageForAlias(alias: string, image: string, enabled: boolean): string { + return enabled ? toSlimImage(alias, image) : image; } export function imageTag(image: string): string | undefined { @@ -152,8 +156,8 @@ export function pinMatchesCurrentImage( export function slimImageForCurrentPin( alias: string, currentRawImage: string, - pin?: string, - enabled = slimImagesEnabled(), + pin: string | undefined, + enabled: boolean, ): string { const trimmed = pin?.trim() ?? ""; const tagged = trimmed.length > 0 ? replaceImageTag(currentRawImage, trimmed) : currentRawImage; @@ -176,6 +180,6 @@ export function isSlimImageRef(image: string): boolean { * 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); +export function usesSlimImageRuntime(image: string, enabled: boolean): boolean { + return enabled && isSlimImageRef(image); } diff --git a/apps/cli/src/shared/services/slim-images.unit.test.ts b/apps/cli/src/shared/services/slim-images.unit.test.ts index 4e082a3b7c..654ea9b1fd 100644 --- a/apps/cli/src/shared/services/slim-images.unit.test.ts +++ b/apps/cli/src/shared/services/slim-images.unit.test.ts @@ -1,4 +1,6 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect } from "effect"; +import { vi } from "vitest"; import { dockerfileServiceImageRaw } from "./dockerfile-images.ts"; import { @@ -11,10 +13,6 @@ import { usesSlimImageRuntime, } from "./slim-images.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - describe("toSlimImage", () => { it.each([ ["pg", "ghcr.io/supabase/cli/postgres"], @@ -106,31 +104,53 @@ describe("toSlimImage", () => { }); 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); - }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.effect.each([ + { value: "true", expected: true }, + { value: "1", expected: true }, + { value: "false", expected: false }, + { value: "0", expected: false }, + { value: "yes", expected: false }, + { value: "TRUE", expected: false }, + { value: "", expected: false }, + { value: undefined, expected: false }, + ])("reads $value as $expected", ({ value, expected }) => + Effect.gen(function* () { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", value); + expect(yield* slimImagesEnabled).toBe(expected); + }), + ); + + it.effect("reads the process environment, not the active ConfigProvider", () => + Effect.gen(function* () { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + const pinned = ConfigProvider.fromEnvRecord({ SUPABASE_USE_SLIM_IMAGES: "true" }); + const failing = ConfigProvider.make(() => + Effect.fail(new ConfigProvider.SourceError({ message: "injected" })), + ); + for (const provider of [pinned, failing]) { + expect( + yield* slimImagesEnabled.pipe( + Effect.provideService(ConfigProvider.ConfigProvider, provider), + ), + ).toBe(false); + } + }), + ); }); 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( + expect(slimImageForAlias("pg", "supabase/postgres:17.6.1.165", false)).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( + expect(slimImageForAlias("pg", "supabase/postgres:17.6.1.165", true)).toBe( "ghcr.io/supabase/cli/postgres:17.6.1.165", ); }); @@ -138,14 +158,12 @@ describe("slimImageForAlias", () => { 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); + expect(usesSlimImageRuntime("ghcr.io/supabase/cli/postgres:17.6.1.165", false)).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); + expect(usesSlimImageRuntime("ghcr.io/supabase/cli/auth:v2.196.0", true)).toBe(true); + expect(usesSlimImageRuntime("supabase/gotrue:v2.196.0", true)).toBe(false); }); }); @@ -162,22 +180,22 @@ describe("pinMatchesCurrentImage", () => { 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( + expect(slimImageForCurrentPin("storage", current, undefined, true)).toBe( + toSlimImage("storage", current), + ); + expect(slimImageForCurrentPin("storage", current, currentTag, true)).toBe( toSlimImage("storage", current), ); - expect(slimImageForCurrentPin("storage", current, "v1.67.0")).toBe( + expect(slimImageForCurrentPin("storage", current, "v1.67.0", true)).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( + expect(slimImageForCurrentPin("storage", current, "v1.67.0", false)).toBe( "supabase/storage-api:v1.67.0", ); });