diff --git a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md index 40157b5ebf..e7382f532d 100644 --- a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md @@ -53,14 +53,13 @@ validation is performed on the discovered URLs, also matching the Go CLI. ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------- | -| `0` | clean shutdown after `SIGINT`, `SIGTERM`, or stdin close | -| `1` | Docker unavailable / `docker info` fails | -| `1` | local DB container is not running | -| `1` | invalid inspect flag combination or invalid project/auth config | -| `1` | env file, signing key, import map, or function bind resolution failure | -| `1` | edge-runtime container startup, log streaming, or restart loop failure | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | clean shutdown after `SIGINT`, `SIGTERM`, or stdin close | +| `1` | local DB container is not running, or the Docker daemon is unreachable (surfaces from the DB inspect as `failed to inspect service: …` plus the Docker Desktop install suggestion) | +| `1` | invalid inspect flag combination or invalid project/auth config | +| `1` | env file, signing key, import map, or function bind resolution failure | +| `1` | edge-runtime container startup, log streaming, or restart loop failure | ## Telemetry Events Fired diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 5b8d75be10..9988f167a4 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -31,7 +31,6 @@ import { import type { LegacyFunctionsServeFlags } from "./serve.handler.ts"; const deployMockState = vi.hoisted(() => ({ - isDockerRunning: true, runCalls: [] as Array<{ command: string; args: ReadonlyArray; @@ -59,9 +58,12 @@ const deployMockState = vi.hoisted(() => ({ } // Never resolves — lets a test fork+interrupt while this specific call is in flight, // matching Effect's own canonical "forever pending, interruptible" primitive. - | { pending: true }), + | { pending: true } + // Fails the effect itself — models `spawnContainerCli` failing to spawn + // any container runtime (neither docker nor podman on PATH), as opposed + // to a spawned process exiting non-zero. + | { failure: Error }), reset() { - this.isDockerRunning = true; this.runCalls = []; this.networkCalls = []; this.volumeCalls = []; @@ -77,7 +79,6 @@ vi.mock("../../../../shared/functions/deploy.ts", async () => { return { ...actual, - isDockerRunning: () => Effect.succeed(deployMockState.isDockerRunning), ensureDockerNetwork: (networkMode: string, projectId: string) => Effect.sync(() => { deployMockState.networkCalls.push({ networkMode, projectId }); @@ -126,7 +127,9 @@ vi.mock("../../../../shared/functions/deploy.ts", async () => { stdout: "", stderr: "", }; - return "pending" in result ? Effect.never : Effect.succeed(result); + if ("pending" in result) return Effect.never; + if ("failure" in result) return Effect.fail(result.failure); + return Effect.succeed(result); }), }; }); @@ -511,18 +514,17 @@ describe("legacy functions serve integration", () => { }, }); + // Bare `kong reload`, matching Go's `restartEdgeRuntime` + // (`internal/functions/serve/serve.go:129`) — the `--nginx-conf` + // template argument belongs to `start`'s Kong bring-up, not reload. expect(deployMockState.runCalls).toContainEqual({ command: "docker", - args: [ - "exec", - "supabase_kong_test-project", - "kong", - "reload", - "--nginx-conf", - "/home/kong/custom_nginx.template", - ], + args: ["exec", "supabase_kong_test-project", "kong", "reload"], options: { stdout: "ignore", stderr: "pipe" }, }); + expect(deployMockState.runCalls.some((call) => call.args.includes("--nginx-conf"))).toBe( + false, + ); expect(childSpawner.spawned).toEqual([ { @@ -1107,6 +1109,10 @@ describe("legacy functions serve integration", () => { path: join(tempRoot.current, "supabase", "functions", "hello", "index.ts"), type: "update", }, + { + path: join(tempRoot.current, "supabase", "functions", "hello", "helper.ts"), + type: "create", + }, ]); const error = yield* Fiber.join(fiber).pipe(Effect.flip); @@ -1120,7 +1126,15 @@ describe("legacy functions serve integration", () => { (call) => call.command === "docker" && call.args[0] === "run", ), ).toHaveLength(2); - expect(out.stderrText).toContain("File change detected:"); + // The file-change line prints the fsnotify op token Go prints + // (`event.Op.String()`, `internal/functions/serve/watcher.go:100`) — + // WRITE/CREATE/REMOVE — not the internal event-type name. + expect(out.stderrText).toContain( + `File change detected: ${join(tempRoot.current, "supabase", "functions", "hello", "index.ts")} (WRITE)`, + ); + expect(out.stderrText).toContain( + `File change detected: ${join(tempRoot.current, "supabase", "functions", "hello", "helper.ts")} (CREATE)`, + ); // `functions serve`'s restart wrapper (`startEdgeRuntime`, Go's // `restartEdgeRuntime`) reloads Kong after each successful bring-up — @@ -1197,12 +1211,27 @@ describe("legacy functions serve integration", () => { it.live("does not remove the existing runtime when interrupted before startup owns it", () => { const processControl = mockQueuedProcessControl(); + // Block startup at the DB assertion (`container inspect`) — the last + // pre-ownership step under Go's ordering (`serve.go:110-120`: config load + // → assert DB → only THEN remove the existing container). The remote-JWKS + // fetch is post-assertion (`serve.go:141`), so if the ordering ever + // regresses to fetch-first, this test hangs at the pending fetch instead + // of reaching the inspect and fails on the waitFor timeout. + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { pending: true }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; return Effect.gen(function* () { const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation( () => new Promise(() => { - // Intentionally pending to keep startup in pre-removal work. + // Intentionally pending — must never be reached before the assertion. }), ); @@ -1235,7 +1264,16 @@ describe("legacy functions serve integration", () => { Effect.forkChild({ startImmediately: true }), ); - yield* waitFor(() => fetchMock.mock.calls.length > 0, "timed out waiting for JWKS fetch"); + yield* waitFor( + () => + deployMockState.runCalls.some( + (call) => + call.command === "docker" && + call.args[0] === "container" && + call.args[1] === "inspect", + ), + "timed out waiting for the DB inspect", + ); processControl.signal("SIGINT"); const exit = yield* Fiber.await(fiber); @@ -1249,6 +1287,9 @@ describe("legacy functions serve integration", () => { call.args.includes("supabase_edge_runtime_test-project"), ), ).toBe(false); + // No remote JWKS request either — Go resolves JWKS only after the DB + // assertion succeeds (`serve.go:141`). + expect(fetchMock).not.toHaveBeenCalled(); expect(out.stdoutText).toContain("Stopped serving"); }); }); @@ -1865,6 +1906,80 @@ describe("legacy functions serve integration", () => { }); }); + it.live("uppercases config secret names, skipping empty and unresolved values", () => { + // Go's config loader uppercases every `[edge_runtime.secrets]` key with + // `strings.ToUpper` (`pkg/config/config.go:766-771`, viper #1014 + // workaround) before `set.ListSecrets` (`internal/secrets/set/set.go:48-52`) + // reads the map, and ListSecrets keeps only entries with a non-empty + // SHA256, i.e. it skips empty values and still-unresolved `env(VAR)` + // literals (`pkg/config/secret.go:94-107`). + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "secrets logs failed" }]); + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig( + [ + 'project_id = "test-project"', + "", + "[edge_runtime.secrets]", + 'my_lower_secret = "keep-me"', + 'EMPTY_SECRET = ""', + 'UNRESOLVED_SECRET = "env(SERVE_SECRET_NEVER_SET)"', + "", + ].join("\n"), + ), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe({ childSpawner }); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("secrets logs failed"); + } + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun).toBeDefined(); + if (dockerRun === undefined) { + throw new Error("expected docker run call"); + } + + const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + expect(envs).toContain("MY_LOWER_SECRET=keep-me"); + expect(envs.some((entry) => entry.startsWith("my_lower_secret="))).toBe(false); + expect(envs.some((entry) => entry.startsWith("EMPTY_SECRET="))).toBe(false); + expect(envs.some((entry) => entry.startsWith("UNRESOLVED_SECRET="))).toBe(false); + }); + }); + it.live("uses the resolved project_id when deriving docker resource names", () => { deployMockState.runHandler = (command, args) => { if (command !== "docker") { @@ -2052,8 +2167,6 @@ describe("legacy functions serve integration", () => { ); it.live("fails inspect flag conflicts before startup work begins", () => { - deployMockState.isDockerRunning = false; - return Effect.gen(function* () { const { layer } = setupServe(); const error = yield* legacyFunctionsServe( @@ -2130,6 +2243,238 @@ describe("legacy functions serve integration", () => { }); }); + it.live("surfaces a down docker daemon as the inspect failure with the install hint", () => { + // Go has no upfront docker precheck in `functions serve` — a down daemon + // surfaces from `AssertSupabaseDbIsRunning`'s container inspect as + // `failed to inspect service: ` with the Docker Desktop + // install hint attached as a suggestion (`internal/utils/misc.go:155-166`, + // `docker.go:350`), AFTER config load resolved the project id. + const daemonDownStderr = + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?"; + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 1, stdout: "", stderr: daemonDownStderr }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toBe(`failed to inspect service: ${daemonDownStderr}`); + // The old TS-only upfront precheck message must never come back. + expect(error.message).not.toContain("failed to run docker"); + } + expect(error).toHaveProperty( + "suggestion", + "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop", + ); + + // Config load ran first (the inspect targets the config-resolved project + // id) and nothing after the failed assert touched docker. + expect(deployMockState.runCalls).toEqual([ + expect.objectContaining({ + command: "docker", + args: ["container", "inspect", "supabase_db_test-project"], + }), + ]); + expect(deployMockState.volumeCalls).toHaveLength(0); + expect(deployMockState.networkCalls).toHaveLength(0); + }); + }); + + it.live("keeps the install hint when no container runtime is installed at all", () => { + // With no `docker` or `podman` binary on PATH the inspect never spawns — + // the shell-out equivalent of Go's missing daemon socket, which + // `client.IsErrConnectionFailed` classifies as a connection failure and so + // gets the Docker Desktop install hint (`internal/utils/misc.go:155-166`, + // `docker.go:350`). The spawn-failure cause must survive into the + // `failed to inspect service: …` message instead of being blanked. + const runtimeNotFoundMessage = + "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH"; + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { failure: new Error(runtimeNotFoundMessage) }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toBe(`failed to inspect service: ${runtimeNotFoundMessage}`); + // The old TS-only upfront precheck message must never come back. + expect(error.message).not.toContain("failed to run docker"); + } + expect(error).toHaveProperty( + "suggestion", + "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop", + ); + }); + }); + + it.live("fails with the config error, not a docker error, when both are broken", () => { + // Go's `restartEdgeRuntime` sanity-checks in order: `flags.LoadConfig` + // first, `AssertSupabaseDbIsRunning` second (`serve.go:107-113`) — a + // malformed config wins over a down docker daemon. + deployMockState.runHandler = () => ({ + exitCode: 1, + stdout: "", + stderr: + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", + }); + + return Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig("not valid toml ][")); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toHaveProperty("_tag", "ProjectConfigParseError"); + expect(deployMockState.runCalls).toHaveLength(0); + }); + }); + + it.live("makes no remote JWKS request when docker is down", () => { + // Go only fetches third-party JWKS inside `ServeFunctions` (`serve.go:141`, + // `ResolveJWKS`), strictly after `AssertSupabaseDbIsRunning` + // (`serve.go:110-113`) — with a down daemon the docker error surfaces + // immediately, without first waiting on OIDC/JWKS requests (two sequential + // 10s-timeout clients, `pkg/config/config.go:1727-1776`). + const daemonDownStderr = + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?"; + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 1, stdout: "", stderr: daemonDownStderr }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + return Effect.gen(function* () { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + throw new Error(`unexpected fetch before the DB assertion: ${url}`); + }); + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + fetchMock.mockRestore(); + }), + ); + + yield* Effect.promise(() => + writeProjectConfig( + [ + 'project_id = "test-project"', + "", + "[auth.third_party.workos]", + "enabled = true", + 'issuer_url = "https://issuer.example"', + "", + ].join("\n"), + ), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toBe(`failed to inspect service: ${daemonDownStderr}`); + } + // The docker-down error must win without a single external request. + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + it.live("fails with the auth config error, not a docker error, when both are broken", () => { + // Go's `jwt_secret` ≥16-chars check runs during config load + // (`pkg/config/apikeys.go:43-47` via `config.go:1156`), BEFORE + // `AssertSupabaseDbIsRunning` — invalid auth config wins over a down + // docker daemon, so the local half of auth resolution must stay ahead of + // the DB assertion. + deployMockState.runHandler = () => ({ + exitCode: 1, + stdout: "", + stderr: + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", + }); + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig( + ['project_id = "test-project"', "", "[auth]", 'jwt_secret = "short"', ""].join("\n"), + ), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toBe( + "Invalid config for auth.jwt_secret. Must be at least 16 characters", + ); + } + expect(deployMockState.runCalls).toHaveLength(0); + }); + }); + it.live("resolves env() config values from root env development files", () => { deployMockState.runHandler = (command, args) => { if (command !== "docker") { diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts index 08f30eba9f..99a8779835 100644 --- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts @@ -93,9 +93,12 @@ export interface LegacyEdgeRuntimeBringUpInput { /** `config.edge_runtime.inspector_port` — only published when `inspectMode` is set, which `start` never does (Go's `serve.RuntimeOption{}` zero value). */ readonly edgeRuntimeInspectorPort: number; /** - * `config.edge_runtime.secrets`, already unwrapped/uppercased — build via - * `shared/functions/serve.ts`'s exported `toPlainEdgeRuntimeConfig(config.edge_runtime).secrets` - * rather than re-deriving the `Redacted`-unwrap/uppercase logic here. + * `config.edge_runtime.secrets`, already unwrapped — keys UPPERCASED (Go's + * `strings.ToUpper` config-load workaround, `pkg/config/config.go:766-771`), + * empty and unresolved-`env()` values filtered out (Go's `SHA256 > 0` gate). + * Build via `shared/functions/serve.ts`'s exported + * `toPlainEdgeRuntimeConfig(config.edge_runtime).secrets` rather than + * re-deriving the uppercase/`Redacted`-unwrap/zero-hash-filter logic here. */ readonly edgeRuntimeSecrets: Readonly>; /** diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts index dfdf0f4866..93aa29ac5e 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -4515,7 +4515,7 @@ content_path = "./templates/custom_notice.html" () => { const { layer, child } = setup({ configContents: - 'project_id = "demo"\n[edge_runtime.secrets]\nMY_SECRET = "shh-do-not-tell"\n', + 'project_id = "demo"\n[edge_runtime.secrets]\nMY_SECRET = "shh-do-not-tell"\nmy_lower_secret = "keep-me"\nEMPTY_SECRET = ""\n', }); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -4528,6 +4528,14 @@ content_path = "./templates/custom_notice.html" expect(envFilePath).toBeDefined(); const envFileContent = readFileSync(envFilePath ?? "", "utf-8"); expect(envFileContent).toContain("MY_SECRET=shh-do-not-tell"); + // Names reach the container UPPERCASED — Go's config loader applies + // `strings.ToUpper` to every secret key (`pkg/config/config.go:766-771`) + // — and empty values are skipped — Go's `set.ListSecrets` SHA256>0 + // gate (`internal/secrets/set/set.go:48-52`), shared with + // `functions serve` via `toPlainEdgeRuntimeConfig`. + expect(envFileContent).toContain("MY_LOWER_SECRET=keep-me"); + expect(envFileContent).not.toContain("my_lower_secret="); + expect(envFileContent).not.toContain("EMPTY_SECRET="); }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 4708903b38..07b10dae3b 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -28,7 +28,13 @@ class LegacyContainerRuntimeNotFoundError extends Data.TaggedError( readonly message: string; }> {} -const RUNTIME_NOT_FOUND_MESSAGE = +/** + * Exported so `legacy-docker-suggest.ts`'s daemon-unreachable matcher can test + * against this literal directly instead of a hardcoded copy of the substring — + * keeping the producer and the classifier from drifting apart if this ever gets + * reworded. + */ +export const legacyContainerRuntimeNotFoundMessage = "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH"; /** @@ -64,7 +70,9 @@ export const legacySpawnContainerCliWithRuntime = ( Effect.map((handle) => ({ handle, runtime: podmanRuntime })), Effect.catch(() => Effect.fail( - new LegacyContainerRuntimeNotFoundError({ message: RUNTIME_NOT_FOUND_MESSAGE }), + new LegacyContainerRuntimeNotFoundError({ + message: legacyContainerRuntimeNotFoundMessage, + }), ), ), ), @@ -100,21 +108,19 @@ export const containerCliExitCode = ( options?: ChildProcess.CommandOptions, podmanArgs?: ReadonlyArray, ) => - spawner - .exitCode(ChildProcess.make("docker", args, options)) - .pipe( - Effect.catch(() => - spawner - .exitCode(ChildProcess.make("podman", podmanArgs ?? args, options)) - .pipe( - Effect.catch(() => - Effect.fail( - new LegacyContainerRuntimeNotFoundError({ message: RUNTIME_NOT_FOUND_MESSAGE }), - ), - ), + spawner.exitCode(ChildProcess.make("docker", args, options)).pipe( + Effect.catch(() => + spawner.exitCode(ChildProcess.make("podman", podmanArgs ?? args, options)).pipe( + Effect.catch(() => + Effect.fail( + new LegacyContainerRuntimeNotFoundError({ + message: legacyContainerRuntimeNotFoundMessage, + }), ), + ), ), - ); + ), + ); function collectDockerCliText(stream: Stream.Stream) { const decoder = new TextDecoder(); diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts index e80dbf5ae8..331b482212 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts @@ -4,6 +4,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { containerCliExitCode, + legacyContainerRuntimeNotFoundMessage, legacyDescribeContainerCliFailure, legacyDockerSupportsVolumePruneAllFlag, spawnContainerCli, @@ -120,7 +121,7 @@ describe("containerCliExitCode", () => { Effect.flip, Effect.map((error) => { expect(legacyDescribeContainerCliFailure(error)).toBe( - "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH", + legacyContainerRuntimeNotFoundMessage, ); }), ); @@ -204,7 +205,9 @@ describe("legacyDescribeContainerCliFailure", () => { return containerCliExitCode(mock.spawner, ["ps"]).pipe( Effect.flip, Effect.map((error) => { - expect(legacyDescribeContainerCliFailure(error)).toContain("docker: command not found"); + expect(legacyDescribeContainerCliFailure(error)).toContain( + legacyContainerRuntimeNotFoundMessage, + ); }), ); }); diff --git a/apps/cli/src/legacy/shared/legacy-docker-suggest.ts b/apps/cli/src/legacy/shared/legacy-docker-suggest.ts index 76397c3ab1..610957edd1 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-suggest.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-suggest.ts @@ -1,3 +1,5 @@ +import { legacyContainerRuntimeNotFoundMessage } from "./legacy-container-cli.ts"; + /** * Go's Docker prerequisite hint (`apps/cli-go/internal/utils/docker.go:350`, * `suggestDockerInstall`). Go sets it as `CmdSuggestion` — rendered as a separate @@ -12,10 +14,38 @@ export const LEGACY_SUGGEST_DOCKER_INSTALL = * subprocess-stderr equivalent of Go's `client.IsErrConnectionFailed` (which * inspects the Docker API client error). The docker / podman CLIs print * "Cannot connect to the Docker daemon …" / "Cannot connect to Podman …" (often - * followed by "Is the docker daemon running?") when the socket is down. + * followed by "Is the docker daemon running?") when the socket is down, and + * "permission denied while trying to connect to the Docker daemon socket …" + * when the socket exists but the user can't open it — the pinned Docker SDK + * classifies that permission error as a connection failure too + * (`client/request.go:144-152`, docker/docker v28.5.2: `os.IsPermission` → + * `errConnectionFailed`), so Go surfaces the install hint for it as well. + * + * Also matches `spawnContainerCli`'s runtime-not-found message + * (`legacyContainerRuntimeNotFoundMessage`, imported from + * `legacy-container-cli.ts` rather than duplicated here so the producer and + * this classifier can't drift apart): a missing container-CLI binary is the + * shell-out equivalent of Go's missing daemon socket — on a machine with no + * Docker installed, Go's socket dial fails, `client.IsErrConnectionFailed` + * fires, and the install hint is exactly the guidance that case needs + * (`misc.go:155-166`). + * + * "error during connect" is the pinned SDK's uniform outer wrap for every + * transport-level failure (`client/request.go:175-185`, docker/docker + * v28.5.2) and therefore the closest 1:1 stderr equivalent of + * `errConnectionFailed`'s breadth. It notably covers Windows daemon-down, + * where a failed npipe open is wrapped as "error during connect: this error + * may indicate that the docker daemon is not running: …" (elevated, + * `request.go:181`) or "… the docker client must be run with elevated + * privileges …" (non-elevated, `request.go:178`) — word orders none of the + * Unix-socket phrases match. The inner OS text ("The system cannot find the + * file specified") is localized per the SDK's own comment + * (`request.go:172-174`), so it is deliberately not matched. */ export function legacyIsDockerDaemonUnreachable(stderr: string): boolean { - return /cannot connect to the docker daemon|cannot connect to podman|is the docker daemon running/iu.test( - stderr, + return ( + /cannot connect to the docker daemon|cannot connect to podman|is the docker daemon running|permission denied while trying to connect|error during connect/iu.test( + stderr, + ) || stderr.includes(legacyContainerRuntimeNotFoundMessage) ); } diff --git a/apps/cli/src/legacy/shared/legacy-docker-suggest.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-suggest.unit.test.ts index 0fcadd4f22..2ca3402344 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-suggest.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-suggest.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { legacyContainerRuntimeNotFoundMessage } from "./legacy-container-cli.ts"; import { LEGACY_SUGGEST_DOCKER_INSTALL, legacyIsDockerDaemonUnreachable, @@ -15,12 +16,35 @@ describe("legacyIsDockerDaemonUnreachable", () => { // Case-insensitive + the podman phrasing. expect(legacyIsDockerDaemonUnreachable("cannot connect to podman")).toBe(true); expect(legacyIsDockerDaemonUnreachable("Is the docker daemon running?")).toBe(true); + // Socket permission errors are connection failures in the pinned Docker + // SDK (`client/request.go:144-152`, v28.5.2: `os.IsPermission` → + // `errConnectionFailed`), so Go attaches the install hint for them too. + expect( + legacyIsDockerDaemonUnreachable( + "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock", + ), + ).toBe(true); + // No container runtime installed at all (`spawnContainerCli`'s + // runtime-not-found message) — the shell-out equivalent of Go's missing + // daemon socket, which `IsErrConnectionFailed` also classifies as a + // connection failure, so the install hint applies. + expect(legacyIsDockerDaemonUnreachable(legacyContainerRuntimeNotFoundMessage)).toBe(true); + // Windows daemon-down: a failed npipe open is wrapped "error during + // connect" by the pinned SDK (`client/request.go:175-185`, v28.5.2) — + // both the elevated and the non-elevated variants. + expect( + legacyIsDockerDaemonUnreachable( + 'error during connect: this error may indicate that the docker daemon is not running: Get "http://%2F%2F.%2Fpipe%2Fdocker_engine/v1.51/containers/supabase_db_test/json": open //./pipe/docker_engine: The system cannot find the file specified.', + ), + ).toBe(true); + expect( + legacyIsDockerDaemonUnreachable( + "error during connect: in the default daemon configuration on Windows, the docker client must be run with elevated privileges to connect: open //./pipe/docker_engine: Access is denied.", + ), + ).toBe(true); }); - it("does not flag an unrelated inspect failure (e.g. a permission error)", () => { - expect(legacyIsDockerDaemonUnreachable("permission denied while trying to connect")).toBe( - false, - ); + it("does not flag an unrelated inspect failure", () => { expect(legacyIsDockerDaemonUnreachable("Error: No such container: supabase_db_x")).toBe(false); expect(legacyIsDockerDaemonUnreachable("")).toBe(false); }); diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 6de36bd34f..d519c85cd6 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -2984,8 +2984,9 @@ export function legacyResolveLocalConfigValues( * JWKS fetch) there would tax two commands that never render a JWKS. This is a standalone sibling * `start`-only callers invoke separately, alongside (not instead of) `legacyResolveLocalConfigValues`. * - * Divergences from the structurally similar (but functionally unrelated) `resolveAuthArtifacts` in - * `shared/functions/serve.ts` (Go's equivalent call site for THAT function is + * Divergences from the structurally similar (but functionally unrelated) + * `resolveLocalAuthArtifacts`/`finalizeAuthArtifacts` pair in + * `shared/functions/serve.ts` (Go's equivalent call site for THAT pair is * `internal/functions/serve/`, out of scope for this port) — deliberately NOT copied here: * - a remote-JWKS fetch failure is a hard, propagating error here (matching `start.go:274-277` * returning the error outright); `serve.ts` instead swallows the failure and continues with diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index d6aef1658c..d73cb686c0 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -3018,7 +3018,7 @@ describe("legacyResolveLocalJwks", () => { }); // The key divergence from `shared/functions/serve.ts`'s own (unrelated) - // `resolveAuthArtifacts`: Go's `start` treats a remote-JWKS fetch failure as a hard, + // `finalizeAuthArtifacts`: Go's `start` treats a remote-JWKS fetch failure as a hard, // command-failing error (`internal/start/start.go:274-277`) — `legacyResolveLocalJwks` // must propagate it too, not swallow it and continue with zero remote keys. it("fails the whole resolution when the remote JWKS fetch fails, unlike functions serve's leniency", async () => { diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index a58ef2c432..ecbcc44049 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -1327,7 +1327,7 @@ export const runChildProcess = Effect.fnUntraced(function* ( return { exitCode, stdout, stderr }; }); -export const isDockerRunning = Effect.fnUntraced(function* () { +const isDockerRunning = Effect.fnUntraced(function* () { const result = yield* runChildProcess("docker", ["info"], { stdout: "ignore", stderr: "ignore", diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index ab0f154d7d..6602423122 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -17,14 +17,21 @@ import { sign as signJwtBytes, type JsonWebKeyInput, } from "node:crypto"; -import { watch } from "node:fs"; +import { existsSync, watch } from "node:fs"; import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { styleText } from "node:util"; import { Cause, Duration, Effect, Layer, Option, Queue, Redacted, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { spawnContainerCli } from "../../legacy/shared/legacy-container-cli.ts"; +import { + legacyDescribeContainerCliFailure, + spawnContainerCli, +} from "../../legacy/shared/legacy-container-cli.ts"; import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-registry.ts"; +import { + LEGACY_SUGGEST_DOCKER_INSTALL, + legacyIsDockerDaemonUnreachable, +} from "../../legacy/shared/legacy-docker-suggest.ts"; import { parseDotEnv } from "../../legacy/shared/legacy-dotenv.ts"; import { resolveRemoteJwks, @@ -47,7 +54,6 @@ import { dockerWorkdirLabel, ensureDockerNamedVolume, ensureDockerNetwork, - isDockerRunning, localDockerId, normalizeProjectId, rawFunctionConfigRecord, @@ -184,11 +190,11 @@ export interface StartedRuntime { /** * Every already-resolved secret/key {@link startEdgeRuntimeContainer} needs, - * matching {@link resolveAuthArtifacts}'s return shape. Named and exported so + * matching {@link finalizeAuthArtifacts}'s return shape. Named and exported so * a caller outside this module (`start`'s own edge-runtime bring-up, * `legacy/commands/start/services/edge-runtime.service.ts`) can build the * exact same shape from values it has already resolved itself, instead of - * calling {@link resolveAuthArtifacts} (which re-reads `config.toml`/signing + * calling {@link resolveLocalAuthArtifacts} (which re-reads `config.toml`/signing * keys independently — correct for the standalone `functions serve` command, * but would risk resolving different secrets than the rest of a `start` * stack for a caller that already has these values). @@ -228,7 +234,7 @@ export interface ServeEdgeRuntimeContainerConfig { * Input to {@link startEdgeRuntimeContainer} — the reusable "bring up one * Edge Runtime container" core extracted from `serveFunctions`'s interactive * loop, kept independent of BOTH `functions serve`'s own config-loading - * (`resolveServeConfig`/`resolveAuthArtifacts`) and its file-watch/log-stream + * (`resolveServeConfig`/`resolveLocalAuthArtifacts`) and its file-watch/log-stream * loop, so `start`'s bring-up can call it directly with values it has already * resolved through its own pipeline (see `internal-db-connection.ts` for why * {@link dbUrl} specifically must be caller-supplied rather than hardcoded). @@ -284,12 +290,19 @@ export const serveFileWatcherLayer = Layer.sync(FileWatcher, () => Stream.callback, FileWatcherError>((queue) => Effect.acquireRelease( Effect.sync(() => { - const watcher = watch(root, { recursive: true }, (_eventType, filename) => { + const watcher = watch(root, { recursive: true }, (eventType, filename) => { const pathname = filename === null || filename === undefined || filename.length === 0 ? root : resolve(root, filename.toString()); - Queue.offerUnsafe(queue, [{ path: pathname, type: "update" }]); + // Node's `fs.watch` only distinguishes "rename" (create/delete/ + // rename) from "change" (write); Go prints the real fsnotify op + // (`internal/functions/serve/watcher.go:100`). The closest + // recoverable equivalent is an existence check on "rename" + // events: present → create, gone → delete; "change" → update. + const type: FileWatchEvent["type"] = + eventType === "rename" ? (existsSync(pathname) ? "create" : "delete") : "update"; + Queue.offerUnsafe(queue, [{ path: pathname, type }]); }); watcher.on("error", (cause) => { Queue.failCauseUnsafe(queue, Cause.fail(new FileWatcherError({ path: root, cause }))); @@ -383,7 +396,7 @@ function toPlainAuthConfig( /** * Exported so `start`'s own edge-runtime bring-up * (`legacy/commands/start/services/edge-runtime.service.ts`) can reuse this - * exact `Redacted`-unwrapping/key-uppercasing logic against its own, + * exact `Redacted`-unwrapping/zero-hash-filtering logic against its own, * already-loaded `ProjectConfig` instead of duplicating it — see * {@link ServeEdgeRuntimeContainerConfig}'s doc comment. */ @@ -394,9 +407,24 @@ export function toPlainEdgeRuntimeConfig( policy: reveal(edgeRuntime.policy) ?? "", inspector_port: edgeRuntime.inspector_port, deno_version: edgeRuntime.deno_version, + // Go's config loader rewrites every `[edge_runtime.secrets]` key with + // `strings.ToUpper` (`pkg/config/config.go:766-771`, the viper #1014 + // workaround) before `set.ListSecrets` + // (`internal/secrets/set/set.go:48-52`) reads the map, so secret names + // always reach the container env UPPERCASED regardless of authored + // casing. ListSecrets then keeps only entries with a non-empty SHA256: + // `DecryptSecretHookFunc` (`pkg/config/secret.go:94-107`) leaves the + // SHA256 empty exactly when the value is empty or a still-unresolved + // `env(VAR)` literal. In the TS pipeline `resolveProjectSubtree` wraps + // resolved secret leaves in `Redacted` and leaves unresolved `env()` + // literals as plain strings, so `Redacted.isRedacted` + non-empty mirrors + // both zero-hash cases — the same guard `secrets set` uses + // (`legacy/commands/secrets/set/set.handler.ts`). secrets: Object.fromEntries( Object.entries(edgeRuntime.secrets ?? {}).flatMap(([name, value]) => - Redacted.isRedacted(value) ? [[name.toUpperCase(), Redacted.value(value)] as const] : [], + Redacted.isRedacted(value) && Redacted.value(value).length > 0 + ? [[name.toUpperCase(), Redacted.value(value)] as const] + : [], ), ), }; @@ -488,7 +516,36 @@ async function readSigningKeys(pathname: string): Promise; } -const resolveAuthArtifacts = Effect.fnUntraced(function* ( +/** + * {@link resolveLocalAuthArtifacts}'s return shape — everything + * {@link finalizeAuthArtifacts} needs to assemble the final + * {@link ServeAuthArtifacts} once the remote-JWKS fetch is allowed to run + * (i.e. after the DB assertion — see {@link startEdgeRuntime}). + */ +interface ServeLocalAuthArtifacts { + readonly publishableKey: string; + readonly secretKey: string; + readonly jwtSecret: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + /** Third-party issuer to fetch remote JWKS from, if one is configured. */ + readonly issuerUrl: string | undefined; + /** Local JWKS entries (signing keys / oct fallback), appended AFTER any remote keys (`config.go:1776-1786`). */ + readonly localKeys: ReadonlyArray; +} + +/** + * Config-load-time auth resolution — exactly the work Go performs during + * `flags.LoadConfig`/`Config.Validate`, BEFORE `AssertSupabaseDbIsRunning`: + * the signing-keys read (`pkg/config/config.go:1110-1115`), the + * `auth.jwt_secret` ≥16-chars check (`pkg/config/apikeys.go:43-47` via + * `config.go:1156`), and anon/service-role key generation. Deliberately does + * NOT fetch remote JWKS: Go only does that inside `ServeFunctions` + * (`serve.go:141` → `ResolveJWKS`, `config.go:1727-1776`), after the DB + * assertion — that half lives in {@link finalizeAuthArtifacts} so a config + * error here still beats a docker-down error, matching Go's precedence. + */ +const resolveLocalAuthArtifacts = Effect.fnUntraced(function* ( auth: PlainServeAuthConfig, configPath: string | undefined, ) { @@ -538,7 +595,6 @@ const resolveAuthArtifacts = Effect.fnUntraced(function* ( : auth.service_role_key; const shouldUseJwtSecretFallback = signingKeysPath.length === 0; - const keys: unknown[] = []; // Go's `Auth.ThirdParty.validate()` (the "at most one enabled" + required-field checks // `resolveThirdPartyIssuerUrl` performs) only runs inside `Config.Validate`'s `if // c.Auth.Enabled` block (`config.go:1087-1153`), but `functions serve`'s own JWKS resolution @@ -548,14 +604,8 @@ const resolveAuthArtifacts = Effect.fnUntraced(function* ( const issuerUrl = auth.enabled ? resolveThirdPartyIssuerUrl(auth.third_party) : thirdPartyIssuerUrlUnchecked(auth.third_party); - if (issuerUrl !== undefined) { - const remoteJwks = yield* Effect.tryPromise({ - try: () => resolveRemoteJwks(issuerUrl), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }).pipe(Effect.catch(() => Effect.succeed([] as ReadonlyArray))); - keys.push(...remoteJwks); - } - keys.push( + const localKeys: unknown[] = []; + localKeys.push( ...(signingKeys.length > 0 ? signingKeys.map(toPublicJwk) : shouldUseJwtSecretFallback @@ -563,7 +613,7 @@ const resolveAuthArtifacts = Effect.fnUntraced(function* ( : []), ); if (shouldUseJwtSecretFallback) { - keys.push({ + localKeys.push({ kty: "oct", k: Buffer.from(jwtSecret).toString("base64url"), }); @@ -581,8 +631,43 @@ const resolveAuthArtifacts = Effect.fnUntraced(function* ( jwtSecret, anonKey, serviceRoleKey, + issuerUrl, + localKeys, + } satisfies ServeLocalAuthArtifacts; +}); + +/** + * The post-assertion half of `functions serve`'s auth resolution — Go's + * `ResolveJWKS` call inside `ServeFunctions` (`serve.go:141`, + * `pkg/config/config.go:1727-1786`): fetch the third-party provider's remote + * JWKS (two sequential OIDC/JWKS requests with 10s-timeout clients, + * `config.go:1727-1776`) with the fetch error discarded (`jwks, _ :=`), then + * assemble the final key set with remote keys FIRST and local keys after + * (`config.go:1776-1786`). Kept separate from + * {@link resolveLocalAuthArtifacts} so `startEdgeRuntime` can run it strictly + * after `assertLocalDbRunning`, matching Go's ordering — with Docker down, no + * external JWKS request is ever made. + */ +const finalizeAuthArtifacts = Effect.fnUntraced(function* (local: ServeLocalAuthArtifacts) { + const keys: unknown[] = []; + if (local.issuerUrl !== undefined) { + const issuerUrl = local.issuerUrl; + const remoteJwks = yield* Effect.tryPromise({ + try: () => resolveRemoteJwks(issuerUrl), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }).pipe(Effect.catch(() => Effect.succeed([] as ReadonlyArray))); + keys.push(...remoteJwks); + } + keys.push(...local.localKeys); + + return { + publishableKey: local.publishableKey, + secretKey: local.secretKey, + jwtSecret: local.jwtSecret, + anonKey: local.anonKey, + serviceRoleKey: local.serviceRoleKey, jwks: JSON.stringify({ keys }), - }; + } satisfies ServeAuthArtifacts; }); const resolveServeConfig = Effect.fnUntraced(function* ( @@ -1034,6 +1119,14 @@ function eventMatchesSpec(spec: WatchSpec, event: FileWatchEvent) { return spec.matchPaths.has(event.path); } +/** + * fsnotify op tokens as Go prints them in the file-change line + * (`event.Op.String()`, `internal/functions/serve/watcher.go:100`). RENAME and + * CHMOD are unreachable here: Node's `fs.watch` folds renames into + * create/delete pairs and does not report metadata-only changes. + */ +const goFileEventOp = { create: "CREATE", update: "WRITE", delete: "REMOVE" } as const; + const waitForRestartSignal = Effect.fnUntraced(function* (watchSpecs: ReadonlyArray) { if (watchSpecs.length === 0) { return yield* Effect.never; @@ -1053,7 +1146,10 @@ const waitForRestartSignal = Effect.fnUntraced(function* (watchSpecs: ReadonlyAr ).pipe( Stream.tap((events) => Effect.forEach(events, (event) => - output.raw(`File change detected: ${event.path} (${event.type})\n`, "stderr"), + output.raw( + `File change detected: ${event.path} (${goFileEventOp[event.type]})\n`, + "stderr", + ), ).pipe(Effect.asVoid), ), Stream.debounce(Duration.millis(500)), @@ -1173,10 +1269,20 @@ const streamContainerLogs = Effect.fnUntraced(function* (containerId: string) { const assertLocalDbRunning = Effect.fnUntraced(function* (projectId: string) { const dbId = localDockerId("db", projectId); + // A spawn failure (neither `docker` nor `podman` on PATH) must keep its + // cause: it is the shell-out equivalent of Go's missing daemon socket, which + // `client.IsErrConnectionFailed` classifies as a connection failure and so + // gets the Docker Desktop install hint (`internal/utils/misc.go:155-166`). + // Blanking stderr here would demote it to a bare "failed to inspect + // service" with no guidance. const result = yield* runChildProcess("docker", ["container", "inspect", dbId], { stdout: "ignore", stderr: "pipe", - }).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); + }).pipe( + Effect.catch((cause) => + Effect.succeed({ exitCode: 1, stdout: "", stderr: legacyDescribeContainerCliFailure(cause) }), + ), + ); if (result.exitCode === 0) { return; @@ -1186,12 +1292,19 @@ const assertLocalDbRunning = Effect.fnUntraced(function* (projectId: string) { return yield* Effect.fail(new Error("supabase start is not running.")); } + const message = + result.stderr.trim().length > 0 + ? `failed to inspect service: ${result.stderr.trim()}` + : "failed to inspect service"; + // Go's `AssertServiceIsRunning` sets `CmdSuggestion = suggestDockerInstall` + // on a daemon-connection failure (`internal/utils/misc.go:155-166`), which + // `recoverAndExit` prints on its own stderr line after the red error + // (`cmd/root.go:300-303`) — mirrored here by the `suggestion` property that + // `normalizeCliError`/`Output.fail` render the same way. return yield* Effect.fail( - new Error( - result.stderr.trim().length > 0 - ? `failed to inspect service: ${result.stderr.trim()}` - : "failed to inspect service", - ), + legacyIsDockerDaemonUnreachable(result.stderr) + ? Object.assign(new Error(message), { suggestion: LEGACY_SUGGEST_DOCKER_INSTALL }) + : new Error(message), ); }); @@ -1205,11 +1318,17 @@ const bestEffortRemoveContainer = Effect.fnUntraced(function* (containerId: stri const reloadKong = Effect.fnUntraced(function* (projectId: string) { const output = yield* Output; const kongId = localDockerId("kong", projectId); - const result = yield* runChildProcess( - "docker", - ["exec", kongId, "kong", "reload", "--nginx-conf", "/home/kong/custom_nginx.template"], - { stdout: "ignore", stderr: "pipe" }, - ).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); + // Bare `kong reload`, exactly Go's `restartEdgeRuntime` + // (`internal/functions/serve/serve.go:129`). The `--nginx-conf + // /home/kong/custom_nginx.template` argument belongs to `start`'s Kong + // bring-up entrypoint (`internal/start/start.go:589-592`, mirrored by + // `legacy/commands/start/services/kong.service.ts`) — `kong reload` reuses + // the prefix configuration that bring-up already prepared, so passing the + // template again here is not part of Go's serve path. + const result = yield* runChildProcess("docker", ["exec", kongId, "kong", "reload"], { + stdout: "ignore", + stderr: "pipe", + }).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); if (result.exitCode !== 0) { const suffix = result.stderr.trim().length > 0 ? ` ${result.stderr.trim()}` : ""; @@ -1580,14 +1699,16 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { readonly inspectMode: FunctionsServeInspectMode | undefined; }) { const output = yield* Output; - if (!(yield* isDockerRunning())) { - return yield* Effect.fail( - new Error( - "failed to run docker. Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop", - ), - ); - } - + // Deliberately NO docker precheck here — Go's `restartEdgeRuntime` + // (`internal/functions/serve/serve.go:107-113`) runs its sanity checks in + // order: `flags.LoadConfig` first, then `utils.AssertSupabaseDbIsRunning()`. + // A down Docker daemon therefore surfaces from the DB inspect below + // (`assertLocalDbRunning`) as `failed to inspect service: …` with the + // Docker Desktop install hint as a suggestion (`misc.go:155-166`), never as + // an upfront `failed to run docker.` failure. The remote-JWKS fetch is + // likewise held until AFTER that assertion (`finalizeAuthArtifacts` below — + // Go only fetches inside `ServeFunctions`, `serve.go:141`), so a down + // daemon never waits on external OIDC/JWKS requests first. const resolved = yield* resolveServeConfig( input.dependencies.projectRoot, input.dependencies.projectIdOverride, @@ -1601,7 +1722,7 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { const networkMode = Option.getOrElse(input.networkId, () => localDockerId("network", projectId), ); - const authArtifacts = yield* resolveAuthArtifacts(resolved.auth, resolved.configPath); + const localAuthArtifacts = yield* resolveLocalAuthArtifacts(resolved.auth, resolved.configPath); const edgeRuntimeVersionOverride = yield* Effect.tryPromise(() => readFile(join(input.dependencies.supabaseDir, ".temp", "edge-runtime-version"), "utf8"), ).pipe( @@ -1627,6 +1748,14 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { // `functions serve`-only wrapper, not the shared core. yield* output.raw("Setting up Edge Functions runtime...\n", "stderr"); + // Go's remote-JWKS fetch happens inside `ServeFunctions` (`serve.go:141`) + // — i.e. after `AssertSupabaseDbIsRunning`, the container removal, and the + // "Setting up…" print above — never before. Finalizing here (rather than + // inside `startEdgeRuntimeContainer`) keeps the shared core's + // caller-supplies-artifacts contract intact for `start`'s bring-up + // (`edge-runtime.service.ts`), which resolves its own JWKS. + const authArtifacts = yield* finalizeAuthArtifacts(localAuthArtifacts); + startedRuntime = yield* startEdgeRuntimeContainer({ config: { projectId,