diff --git a/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts b/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts new file mode 100644 index 0000000000..68a7b1c11b --- /dev/null +++ b/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts @@ -0,0 +1,470 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import http from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { mockContainerCliSpawner } from "../../../tests/helpers/local-reset.ts"; +import { DebugLogger } from "../debug-logger.service.ts"; +import { + LocalDockerEngine, + dockerEndpointSocketPath, + isLocalDbRunning, + localDockerEngineLayer, +} from "./local-db-running.ts"; + +describe("dockerEndpointSocketPath", () => { + it("maps a unix:// endpoint to its filesystem path", () => { + expect(dockerEndpointSocketPath("unix:///var/run/docker.sock")).toBe("/var/run/docker.sock"); + }); + + it("maps an npipe:// endpoint to its \\\\.\\pipe form", () => { + expect(dockerEndpointSocketPath("npipe:////./pipe/dockerDesktopLinuxEngine")).toBe( + "\\\\.\\pipe\\dockerDesktopLinuxEngine", + ); + }); + + it("declines every endpoint the direct transport cannot address", () => { + expect(dockerEndpointSocketPath("tcp://localhost:2375")).toBeUndefined(); + expect(dockerEndpointSocketPath("ssh://user@remote-host")).toBeUndefined(); + expect(dockerEndpointSocketPath("fd://")).toBeUndefined(); + expect(dockerEndpointSocketPath("unix://")).toBeUndefined(); + }); +}); + +function withDockerHost( + endpoint: string, + effect: Effect.Effect, +): Effect.Effect { + return Effect.suspend(() => { + const previous = process.env["DOCKER_HOST"]; + process.env["DOCKER_HOST"] = endpoint; + return effect.pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["DOCKER_HOST"]; + else process.env["DOCKER_HOST"] = previous; + }), + ), + ); + }); +} + +const ENGINE_IDENTITY_HEADERS = { "api-version": "1.55" }; + +const makeEngineServer = (respond: (req: http.IncomingMessage, res: http.ServerResponse) => void) => + Effect.acquireRelease( + Effect.callback<{ + readonly socketPath: string; + readonly requestedUrls: Array; + readonly server: http.Server; + readonly dir: string; + }>((resume) => { + let settled = false; + const dir = mkdtempSync(join(tmpdir(), "ldbeng-")); + const socketPath = join(dir, "d.sock"); + const requestedUrls: Array = []; + const server = http.createServer((req, res) => { + requestedUrls.push(req.url ?? ""); + respond(req, res); + }); + server.once("error", (cause) => { + if (settled) return; + settled = true; + rmSync(dir, { recursive: true, force: true }); + resume(Effect.die(cause)); + }); + server.listen(socketPath, () => { + if (settled) return; + settled = true; + resume(Effect.succeed({ socketPath, requestedUrls, server, dir })); + }); + return Effect.sync(() => { + settled = true; + server.close(); + rmSync(dir, { recursive: true, force: true }); + }); + }), + ({ dir, server }) => + Effect.callback((resume) => { + server.closeAllConnections?.(); + server.close(() => { + rmSync(dir, { recursive: true, force: true }); + resume(Effect.void); + }); + }), + ); + +const engineContainerExists = (containerId: string) => + Effect.gen(function* () { + const engine = yield* LocalDockerEngine; + return yield* engine.containerExists(containerId); + }).pipe(Effect.provide(localDockerEngineLayer)); + +describe("LocalDockerEngine (direct Engine-API transport)", () => { + describe.skipIf(process.platform === "win32")("over a per-test unix-socket Engine server", () => { + it.live("answers present for an Engine 200 inspect payload on the documented route", () => + Effect.scoped( + Effect.gen(function* () { + const engine = yield* makeEngineServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS }); + res.end(JSON.stringify({ Id: "abc123", State: { Status: "created" } })); + }); + const answer = yield* withDockerHost( + `unix://${engine.socketPath}`, + engineContainerExists("supabase_db_engine-probe"), + ); + expect(answer).toEqual(Option.some(true)); + expect(engine.requestedUrls).toEqual(["/containers/supabase_db_engine-probe/json"]); + }), + ), + ); + + it.live("answers absent for an Engine 404", () => + Effect.scoped( + Effect.gen(function* () { + const engine = yield* makeEngineServer((_req, res) => { + res.writeHead(404, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS }); + res.end(JSON.stringify({ message: "No such container" })); + }); + const answer = yield* withDockerHost( + `unix://${engine.socketPath}`, + engineContainerExists("supabase_db_engine-probe"), + ); + expect(answer).toEqual(Option.some(false)); + }), + ), + ); + + it.live("gives no answer for a responder that does not identify as a Docker engine", () => + Effect.scoped( + Effect.gen(function* () { + const engine = yield* makeEngineServer((req, res) => { + if (req.url?.includes("present") === true) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ Id: "not-an-engine" })); + return; + } + if (req.url?.includes("server-header") === true) { + res.writeHead(404, { server: "Docker/28.5.2 (linux)" }); + res.end(JSON.stringify({ message: "No such container" })); + return; + } + res.writeHead(404); + res.end(); + }); + const present = yield* withDockerHost( + `unix://${engine.socketPath}`, + engineContainerExists("present"), + ); + const absent = yield* withDockerHost( + `unix://${engine.socketPath}`, + engineContainerExists("absent"), + ); + const viaServerHeader = yield* withDockerHost( + `unix://${engine.socketPath}`, + engineContainerExists("server-header"), + ); + expect(present).toEqual(Option.none()); + expect(absent).toEqual(Option.none()); + expect(viaServerHeader).toEqual(Option.some(false)); + }), + ), + ); + + it.live("gives no answer for an empty or malformed Engine 200 body", () => + Effect.scoped( + Effect.gen(function* () { + const engine = yield* makeEngineServer((req, res) => { + res.writeHead(200, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS }); + res.end( + req.url?.includes("empty") === true ? "" : JSON.stringify(["not", "an", "object"]), + ); + }); + const empty = yield* withDockerHost( + `unix://${engine.socketPath}`, + engineContainerExists("empty"), + ); + const malformed = yield* withDockerHost( + `unix://${engine.socketPath}`, + engineContainerExists("malformed"), + ); + expect(empty).toEqual(Option.none()); + expect(malformed).toEqual(Option.none()); + }), + ), + ); + + it.live( + "gives no answer for an abnormal Engine status, so the container CLI reproduces it", + () => + Effect.scoped( + Effect.gen(function* () { + const engine = yield* makeEngineServer((_req, res) => { + res.writeHead(500, { + "content-type": "application/json", + ...ENGINE_IDENTITY_HEADERS, + }); + res.end(JSON.stringify({ message: "layer store corrupted" })); + }); + const answer = yield* withDockerHost( + `unix://${engine.socketPath}`, + engineContainerExists("supabase_db_engine-probe"), + ); + expect(answer).toEqual(Option.none()); + }), + ), + ); + + it.live( + "gives no answer when the endpoint accepts the connection but never responds", + () => + Effect.scoped( + Effect.gen(function* () { + const engine = yield* makeEngineServer(() => {}); + const answer = yield* withDockerHost( + `unix://${engine.socketPath}`, + engineContainerExists("supabase_db_engine-probe"), + ); + expect(answer).toEqual(Option.none()); + }), + ), + 15_000, + ); + + it.live("gives no answer for an oversized body", () => + Effect.scoped( + Effect.gen(function* () { + const engine = yield* makeEngineServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS }); + res.end(`{"Id":"${"a".repeat(80 * 1024)}"}`); + }); + const answer = yield* withDockerHost( + `unix://${engine.socketPath}`, + engineContainerExists("supabase_db_engine-probe"), + ); + expect(answer).toEqual(Option.none()); + }), + ), + ); + + it.live("gives no answer when the peer resets after the headers", () => + Effect.scoped( + Effect.gen(function* () { + const engine = yield* makeEngineServer((_req, res) => { + res.writeHead(200, { + "content-type": "application/json", + "content-length": "1000", + ...ENGINE_IDENTITY_HEADERS, + }); + res.write('{"Id":'); + res.socket?.destroy(); + }); + const answer = yield* withDockerHost( + `unix://${engine.socketPath}`, + engineContainerExists("supabase_db_engine-probe"), + ); + expect(answer).toEqual(Option.none()); + }), + ), + ); + + it.live("traces the request, the decline, and the fallback through DebugLogger", () => { + const lines: Array = []; + const recorder = Layer.succeed(DebugLogger, { + debug: (line: string) => + Effect.sync(() => { + lines.push(`debug:${line}`); + }), + http: (method: string, url: string) => + Effect.sync(() => { + lines.push(`http:${method} ${url}`); + }), + }); + const traced = Effect.gen(function* () { + const engine = yield* LocalDockerEngine; + return yield* engine.containerExists("supabase_db_engine-probe"); + }).pipe(Effect.provide(localDockerEngineLayer.pipe(Layer.provide(recorder)))); + return Effect.scoped( + Effect.gen(function* () { + const engine = yield* makeEngineServer((_req, res) => { + res.writeHead(404, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS }); + res.end(JSON.stringify({ message: "No such container" })); + }); + yield* withDockerHost(`unix://${engine.socketPath}`, traced); + expect( + lines.some( + (l) => + l.startsWith("http:GET unix://") && + l.includes("/containers/supabase_db_engine-probe/json"), + ), + ).toBe(true); + // Plainly, then with a password a URL parser mis-locates, then with + // no scheme for one to anchor on at all. + yield* withDockerHost("ssh://user:hunter2@remote-host", traced); + expect( + lines.some( + (l) => l.startsWith("debug:") && l.includes("not directly addressable (ssh)"), + ), + ).toBe(true); + expect(lines.some((l) => l.includes("hunter2") || l.includes("remote-host"))).toBe(false); + yield* withDockerHost("tcp://user:#hunter3@10.0.0.5:2376", traced); + expect(lines.some((l) => l.includes("not directly addressable (tcp)"))).toBe(true); + expect(lines.some((l) => l.includes("hunter3") || l.includes("10.0.0.5"))).toBe(false); + yield* withDockerHost("deploy:hunter4@10.0.0.5:2376", traced); + expect(lines.some((l) => l.includes("not directly addressable (no scheme)"))).toBe(true); + expect(lines.some((l) => l.includes("hunter4") || l.includes("deploy"))).toBe(false); + const missingDir = mkdtempSync(join(tmpdir(), "ldbgone-")); + yield* withDockerHost(`unix://${join(missingDir, "never-created.sock")}`, traced).pipe( + Effect.ensuring( + Effect.sync(() => rmSync(missingDir, { recursive: true, force: true })), + ), + ); + expect( + lines.some((l) => l.startsWith("debug:") && l.includes("no definitive Engine answer")), + ).toBe(true); + }), + ); + }); + + it.live( + "gives no answer at the wall-clock deadline when the peer trickles bytes forever", + () => + Effect.scoped( + Effect.gen(function* () { + const engine = yield* makeEngineServer((_req, res) => { + res.writeHead(200, { + "content-type": "application/json", + ...ENGINE_IDENTITY_HEADERS, + }); + const drip = setInterval(() => { + res.write("a"); + }, 500); + res.on("close", () => { + clearInterval(drip); + }); + }); + const answer = yield* withDockerHost( + `unix://${engine.socketPath}`, + engineContainerExists("supabase_db_engine-probe"), + ); + expect(answer).toEqual(Option.none()); + }), + ), + 15_000, + ); + }); + + it.live("gives no answer when the local socket cannot be dialed", () => { + const missingDir = mkdtempSync(join(tmpdir(), "ldbgone-")); + return withDockerHost( + `unix://${join(missingDir, "never-created.sock")}`, + engineContainerExists("supabase_db_engine-probe"), + ).pipe( + Effect.ensuring(Effect.sync(() => rmSync(missingDir, { recursive: true, force: true }))), + Effect.map((answer) => { + expect(answer).toEqual(Option.none()); + }), + ); + }); + + it.live("gives no answer for an endpoint the transport cannot address (ssh)", () => + withDockerHost( + "ssh://user@remote-host", + engineContainerExists("supabase_db_engine-probe"), + ).pipe( + Effect.map((answer) => { + expect(answer).toEqual(Option.none()); + }), + ), + ); +}); + +describe("isLocalDbRunning", () => { + const probe = (spawnerLayer: Layer.Layer) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = mkdtempSync(join(tmpdir(), "ldbrun-")); + return yield* isLocalDbRunning(spawner, fs, path, workdir, "engine-probe").pipe( + Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true }))), + ); + }).pipe(Effect.provide(spawnerLayer), Effect.provide(BunServices.layer)); + + it.live("trusts a definitive Engine answer without spawning the container CLI", () => { + const asked: Array = []; + const mock = mockContainerCliSpawner(() => ({ exitCode: 0 })); + return probe(mock.layer).pipe( + Effect.provideService(LocalDockerEngine, { + containerExists: (containerId) => + Effect.sync(() => { + asked.push(containerId); + }).pipe(Effect.as(Option.some(true))), + }), + Effect.map((running) => { + expect(running).toBe(true); + expect(asked).toEqual(["supabase_db_engine-probe"]); + expect(mock.spawned).toEqual([]); + }), + ); + }); + + it.live("trusts a definitive Engine 404 without spawning the container CLI", () => { + const mock = mockContainerCliSpawner(() => ({ exitCode: 0 })); + return probe(mock.layer).pipe( + Effect.provideService(LocalDockerEngine, { + containerExists: () => Effect.succeed(Option.some(false)), + }), + Effect.map((running) => { + expect(running).toBe(false); + expect(mock.spawned).toEqual([]); + }), + ); + }); + + it.live("falls back to the container CLI when the Engine gives no answer", () => { + const mock = mockContainerCliSpawner(() => ({ + exitCode: 1, + stderr: ["Error response from daemon: No such container: supabase_db_engine-probe"], + })); + return probe(mock.layer).pipe( + Effect.provideService(LocalDockerEngine, { + containerExists: () => Effect.succeed(Option.none()), + }), + Effect.map((running) => { + expect(running).toBe(false); + expect(mock.spawned.map((s) => s.args)).toEqual([ + ["container", "inspect", "supabase_db_engine-probe"], + ]); + }), + ); + }); + + it.live( + "composes: a real transport failure on the resolved endpoint falls through to the container CLI", + () => { + const mock = mockContainerCliSpawner(() => ({ + exitCode: 1, + stderr: ["Error response from daemon: No such container: supabase_db_engine-probe"], + })); + const missingDir = mkdtempSync(join(tmpdir(), "ldbgone-")); + return withDockerHost( + `unix://${join(missingDir, "never-created.sock")}`, + probe(mock.layer).pipe(Effect.provide(localDockerEngineLayer)), + ).pipe( + Effect.ensuring(Effect.sync(() => rmSync(missingDir, { recursive: true, force: true }))), + Effect.map((running) => { + expect(running).toBe(false); + expect(mock.spawned.map((s) => s.args)).toEqual([ + ["container", "inspect", "supabase_db_engine-probe"], + ]); + }), + ); + }, + ); +}); diff --git a/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts b/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts index f2421a1498..663fd5cf7a 100644 --- a/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts +++ b/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts @@ -1,4 +1,6 @@ -import { Data, Effect, type FileSystem, Option, type Path, Stream } from "effect"; +import http from "node:http"; + +import { Context, Data, Effect, type FileSystem, Layer, Option, type Path, Stream } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import { @@ -10,6 +12,9 @@ import { isContainerNotFoundMessage, spawnContainerCli } from "../container-cli. import { readDbToml } from "../db-config.toml-read.ts"; import { resolveLocalProjectId, localDbContainerId } from "../docker-ids.ts"; import { SUGGEST_DOCKER_INSTALL, isDockerDaemonUnreachable } from "../docker-suggest.ts"; +import { redactHttpUrl } from "../../auth/http-debug.layer.ts"; +import { DebugLogger } from "../debug-logger.service.ts"; +import { resolveDockerDaemonEndpoint } from "../hostname.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -29,6 +34,246 @@ export class LocalDbRunningError extends Data.TaggedError("LocalDbRunningError") } } +/** + * Direct Engine-API access to the locally addressable Docker daemon, so + * {@link isLocalDbRunning} does not depend on a spawnable, responsive + * `docker` CLI binary (issue #6110). `containerExists`: `Option.some(true)` — + * an Engine-identified 200 with a valid inspect payload; `Option.some(false)` + * — an Engine-identified 404; `Option.none()` — anything else (non-addressable + * endpoint, transport failure, silent socket, the 5s wall-clock bound + * expiring, non-Engine responder, abnormal + * status, malformed body), telling the caller to fall back to the container + * CLI, which preserves the established wording, daemon-down classification, + * and Podman fallback. + */ +export class LocalDockerEngine extends Context.Service< + LocalDockerEngine, + { + readonly containerExists: (containerId: string) => Effect.Effect>; + } +>()("supabase/cli/LocalDockerEngine") {} + +/** + * The local socket path for a `unix://`/`npipe://` daemon endpoint + * (`npipe:////./pipe/X` -> `\\.\pipe\X`), as `node:http`'s `socketPath` + * accepts it. Every other scheme returns `undefined` — tcp/ssh/fd endpoints + * may need TLS material or transports only the `docker` CLI carries, so + * callers keep shelling out. Exported for tests (npipe is Windows-only). + */ +export function dockerEndpointSocketPath(endpoint: string): string | undefined { + if (endpoint.startsWith("unix://")) { + const socketPath = endpoint.slice("unix://".length); + return socketPath.length > 0 ? socketPath : undefined; + } + if (endpoint.startsWith("npipe://")) { + const pipePath = endpoint.slice("npipe://".length); + return pipePath.length > 0 ? pipePath.replaceAll("/", "\\") : undefined; + } + return undefined; +} + +/** + * How the endpoint is named in {@link localDockerEngineLayer}'s decline line: + * by its scheme alone. + * + * That is the whole reason the probe declined — `tcp`, `ssh` and `fd` are not + * local sockets — and it is the one part of a `DOCKER_HOST` that cannot carry + * a credential. The rest can: `ssh://user:secret@host` is a supported spelling, + * and once a password holds an unencoded `@`, `/`, `?` or `#` there is no + * telling it apart from an ordinary host and path, so none of it is printed. + */ +function describeEndpoint(endpoint: string | undefined): string { + if (endpoint === undefined) { + return "unresolved context"; + } + // Anchored on `://`, so a value with no scheme cannot report its own first + // segment as one — that segment is the username in `user:secret@host`. + const scheme = /^[a-zA-Z][a-zA-Z0-9+.-]*(?=:\/\/)/.exec(endpoint); + if (scheme === null) { + return "no scheme"; + } + const name = scheme[0].toLowerCase(); + // A socket scheme only reaches the decline branch with nothing after it, so + // naming it alone would read as a contradiction. + return name === "unix" || name === "npipe" ? `${name}, no socket path` : name; +} + +/** + * Socket-inactivity deadline: a connected-but-silent endpoint degrades to the + * container-CLI fallback instead of parking the command (#6110's hang shape). + */ +const ENGINE_PROBE_TIMEOUT_MS = 2000; + +/** + * Absolute wall-clock deadline for one probe. The socket timeout above is + * inactivity-based, so a peer trickling bytes could evade it; past this bound + * the probe is interrupted (aborting the request) and falls back. + */ +const ENGINE_PROBE_DEADLINE_MS = 5000; + +/** + * Body bound (an inspect payload is a few KB); past it the probe stops + * reading and falls back. + */ +const ENGINE_MAX_RESPONSE_BYTES = 64 * 1024; + +/** + * Docker sets `Api-Version`/`Server: Docker/` on every response, 404s + * included (Podman's compat API sends `Api-Version` too). A 200/404 without + * them is some other service on the socket, not an answer — fall back. + */ +const isEngineResponse = (response: http.IncomingMessage): boolean => + response.headers["api-version"] !== undefined || + String(response.headers["server"] ?? "").startsWith("Docker/"); + +/** + * `GET /containers//json` over a local socket / named pipe. Total: every + * terminal state settles exactly once, and anything that is not an + * Engine-identified 200/404 settles `Option.none()`. `agent: false` keeps the + * one-shot connection out of the process-global pool, so nothing outlives the + * probe. + */ +const inspectContainerOverSocket = ( + socketPath: string, + containerId: string, +): Effect.Effect> => + Effect.callback((resume, signal) => { + let settled = false; + const settle = (result: Option.Option) => { + if (settled) return; + settled = true; + resume(Effect.succeed(result)); + }; + const settleNone = () => { + settle(Option.none()); + }; + + let request: http.ClientRequest; + try { + request = http.request( + { + socketPath, + method: "GET", + path: `/containers/${encodeURIComponent(containerId)}/json`, + // HTTP/1.1 requires a Host header; "docker" is what Docker's SDKs send. + headers: { Host: "docker" }, + agent: false, + timeout: ENGINE_PROBE_TIMEOUT_MS, + signal, + }, + (response) => { + const chunks: Array = []; + let size = 0; + response.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > ENGINE_MAX_RESPONSE_BYTES) { + response.destroy(); + settleNone(); + return; + } + chunks.push(chunk); + }); + response.on("error", settleNone); + // A dropped connection can surface as `close` without `end` (no + // `error` guaranteed); the latch no-ops the ordinary post-`end` close. + response.on("close", settleNone); + response.on("end", () => { + if (!isEngineResponse(response)) { + settleNone(); + return; + } + const status = response.statusCode ?? 0; + if (status === 404) { + settle(Option.some(false)); + return; + } + if (status !== 200) { + settleNone(); + return; + } + // A 200 must carry a JSON-object inspect payload to count as "present". + let payload: unknown; + try { + payload = JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + payload = undefined; + } + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + settleNone(); + return; + } + settle(Option.some(true)); + }); + }, + ); + request.on("error", settleNone); + // Node leaves the in-flight socket alive on `timeout` — destroy it. Also + // the universal backstop; deliberately no `request.on("close")`: under + // Bun that fires mid-response, between `data` and `end`. + request.on("timeout", () => { + request.destroy(); + settleNone(); + }); + request.end(); + } catch { + // A transport that cannot even be constructed is a transport failure too. + settleNone(); + return; + } + + return Effect.sync(() => { + settled = true; + request.destroy(); + }); + }); + +/** + * Production {@link LocalDockerEngine}: resolves the endpoint the way the + * `docker` CLI itself would (`DOCKER_HOST` -> context store -> platform + * default), inside `Effect.suspend` so every execution sees the current + * environment. With `DebugLogger` provided (the db families provide it), the + * probe's socket and fallback decisions surface under `--debug` — otherwise + * this is the one HTTP call the debug side channel cannot see. An endpoint it + * cannot address is named by {@link describeEndpoint}, never printed. + */ +export const localDockerEngineLayer: Layer.Layer = Layer.effect( + LocalDockerEngine, + Effect.gen(function* () { + const debugLogger = yield* Effect.serviceOption(DebugLogger); + const debug = (line: string) => + Option.isSome(debugLogger) ? debugLogger.value.debug(line) : Effect.void; + const httpLine = (url: string) => + Option.isSome(debugLogger) ? debugLogger.value.http("GET", redactHttpUrl(url)) : Effect.void; + return LocalDockerEngine.of({ + containerExists: (containerId) => + Effect.suspend(() => { + const endpoint = resolveDockerDaemonEndpoint(); + const socketPath = + endpoint === undefined ? undefined : dockerEndpointSocketPath(endpoint); + if (socketPath === undefined) { + return debug( + `local db engine probe: endpoint not directly addressable (${describeEndpoint(endpoint)}) — using the container CLI`, + ).pipe(Effect.as(Option.none())); + } + return httpLine(`${endpoint}/containers/${containerId}/json`).pipe( + Effect.andThen(inspectContainerOverSocket(socketPath, containerId)), + Effect.timeoutOrElse({ + duration: ENGINE_PROBE_DEADLINE_MS, + orElse: () => Effect.succeed(Option.none()), + }), + Effect.tap((answer) => + Option.isNone(answer) + ? debug( + "local db engine probe: no definitive Engine answer — falling back to the container CLI", + ) + : Effect.void, + ), + ); + }), + }); + }), +); + const decodeChunks = (chunks: ReadonlyArray): string => { const total = chunks.reduce((size, chunk) => size + chunk.length, 0); const bytes = new Uint8Array(total); @@ -41,16 +286,18 @@ const decodeChunks = (chunks: ReadonlyArray): string => { }; /** - * Port of Go's `utils.AssertSupabaseDbIsRunning` (`internal/utils/misc.go:144`): - * inspect the local Postgres container. Resolves `true` when it exists (the - * stack is up) and `false` when the container-CLI reports a missing container — - * Docker's "No such container"/"No such object" or Podman's own "no container with - * name or ID ... found" wording, via the shared `isContainerNotFoundMessage` - * matcher (`../container-cli.ts`) — Go's `ErrNotRunning`. Any other inspect - * failure (e.g. the Docker daemon is - * unreachable) fails with {@link LocalDbRunningError} instead of being - * treated as "not running", matching Go, which returns the wrapped inspect - * error rather than silently treating the database as stopped. + * Answers "does the local Postgres container exist?" (the stack-up probe run + * before any database bootstrap). Resolves `true` when it exists and `false` + * when it definitively does not; any other inspect failure (e.g. the Docker + * daemon is unreachable) fails with {@link LocalDbRunningError} instead + * of being silently treated as "not running". + * + * The probe asks the Engine API directly first ({@link LocalDockerEngine}), + * so a stalled `docker` CLI binary can no longer block it (issue #6110); only + * when the Engine gives no definitive answer does it fall back to the + * container-CLI spawn below, which preserves the Podman fallback and the + * daemon-down classification (via the shared `isContainerNotFoundMessage` + * matcher in `../container-cli.ts`). * * Shared by `db start` (`commands/db/start/start.handler.ts`) and `db reset` * (`commands/db/reset/reset.handler.ts`) — hoisted out of the `db __db-bootstrap` @@ -61,7 +308,7 @@ const decodeChunks = (chunks: ReadonlyArray): string => { * itself no longer exists at all. * * `resolveDbToml` mirrors the seam's own best-effort read: the caller has - * already run Go's `LoadConfig` validation before reaching this check, so here + * already run the config load/validation before reaching this check, so here * we only want the resolved `projectId` and tolerate falling back to the * workdir basename on an unreadable `.env` rather than re-throwing. */ @@ -71,7 +318,7 @@ export function isLocalDbRunning( path: Path.Path, workdir: string, configuredProjectId: string | undefined, -): Effect.Effect { +): Effect.Effect { return Effect.scoped( Effect.gen(function* () { // `warnOnUnresolvedEnv: false` — this doc comment's own `resolveDbToml` note: @@ -92,6 +339,10 @@ export function isLocalDbRunning( workdir, ); const containerId = localDbContainerId(projectId); + // Engine probe first; `Option.none()` falls through to the CLI spawn below. + const engine = yield* LocalDockerEngine; + const engineAnswer = yield* engine.containerExists(containerId); + if (Option.isSome(engineAnswer)) return engineAnswer.value; // Discard stdout (the inspect JSON) so the unconsumed pipe can never // deadlock; only the exit code + stderr matter. const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { diff --git a/apps/cli/src/command-internal/hostname.ts b/apps/cli/src/command-internal/hostname.ts index 108ae6cfc5..01a3d06a6c 100644 --- a/apps/cli/src/command-internal/hostname.ts +++ b/apps/cli/src/command-internal/hostname.ts @@ -100,26 +100,49 @@ function hostFromTcpEndpoint(endpoint: string): string | undefined { } } +/** + * `docker/cli` `load.go`'s per-platform `DefaultDockerHost` — what the + * `"default"` context stands for, consulted only after `DOCKER_HOST` and the + * context store. Hoisted from `commands/start/services/vector.service.ts`. + */ +export function platformDefaultDockerHost(platform: NodeJS.Platform = process.platform): string { + return platform === "win32" ? "npipe:////./pipe/docker_engine" : "unix:///var/run/docker.sock"; +} + +/** + * The daemon endpoint the `docker` CLI itself would dial, without spawning + * it: `DOCKER_HOST`, else the active context's stored endpoint, with + * `"default"` meaning the platform socket. `undefined` for an unreadable + * non-default context — direct-transport callers must then fall back to the + * CLI. Consumed by {@link getHostname} and the Engine probe + * (`db-bootstrap/local-db-running.ts`). + */ +export function resolveDockerDaemonEndpoint(): string | undefined { + const dockerHost = process.env["DOCKER_HOST"]; + if (dockerHost !== undefined && dockerHost.length > 0) { + return dockerHost; + } + const contextName = currentDockerContextName(); + if (contextName === DEFAULT_CONTEXT_NAME) { + return platformDefaultDockerHost(); + } + return dockerContextEndpointHost(contextName); +} + /** * Resolves the hostname used for local Supabase service connections, mirroring * `utils.GetHostname`: * * 1. `SUPABASE_SERVICES_HOSTNAME` env override — set in dev containers or when * the Docker daemon is not reachable on the container's own loopback. - * 2. The Docker daemon host when `DOCKER_HOST` is a `tcp://host:port` endpoint - * (`Docker.DaemonHost()` + `client.ParseHostURL` + `net.SplitHostPort`). - * 3. Otherwise, the ACTIVE DOCKER CONTEXT's daemon endpoint, when it's a - * `tcp://` one — `Docker.DaemonHost()` comes from a client built via - * `command.NewDockerCli()` + `cli.Initialize()`, whose endpoint resolution walks `DOCKER_HOST` -> - * `DOCKER_CONTEXT` -> the config file's `currentContext` -> the context - * store (`docker/cli` `cli/command/cli.go`'s `getDockerEndPoint`/ - * `resolveContextName`) — not just `DOCKER_HOST`. The `docker`/`podman` - * binary this module's callers shell out to for `ps`/`inspect` already - * resolves the same active context itself, so without this step `status` - * could correctly inspect a remote daemon while printing unusable - * `127.0.0.1` API/DB/Studio URLs for it. - * 4. `127.0.0.1` otherwise (the default unix-socket daemon, or an - * unresolvable/malformed context). + * 2. The active Docker daemon endpoint's host, when that endpoint is a + * `tcp://host:port` one — resolved by {@link resolveDockerDaemonEndpoint} + * exactly the way the `docker`/`podman` binary this module's callers shell out + * to for `ps`/`inspect` resolves it itself, so `status` never correctly + * inspects a remote daemon while printing unusable `127.0.0.1` + * API/DB/Studio URLs for it. + * 3. `127.0.0.1` otherwise (the default unix-socket daemon, a non-tcp + * endpoint, or an unresolvable/malformed context). * * Shared across commands that connect to the local stack (`gen types`, * `test db`, `status`, `stop`, and later `db reset` / `db dump`). @@ -129,13 +152,9 @@ export function getHostname(): string { if (override !== undefined && override.length > 0) { return override; } - const dockerHost = process.env["DOCKER_HOST"]; - if (dockerHost !== undefined && dockerHost.length > 0) { - return hostFromTcpEndpoint(dockerHost) ?? LOCAL_HOST; - } - const contextEndpoint = dockerContextEndpointHost(currentDockerContextName()); - if (contextEndpoint !== undefined) { - const host = hostFromTcpEndpoint(contextEndpoint); + const endpoint = resolveDockerDaemonEndpoint(); + if (endpoint !== undefined) { + const host = hostFromTcpEndpoint(endpoint); if (host !== undefined) { return host; } diff --git a/apps/cli/src/command-internal/hostname.unit.test.ts b/apps/cli/src/command-internal/hostname.unit.test.ts index d98fc89709..1ef9f8c449 100644 --- a/apps/cli/src/command-internal/hostname.unit.test.ts +++ b/apps/cli/src/command-internal/hostname.unit.test.ts @@ -4,7 +4,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { configureLoopbackProxyBypass, getHostname } from "./hostname.ts"; +import { + configureLoopbackProxyBypass, + getHostname, + platformDefaultDockerHost, + resolveDockerDaemonEndpoint, +} from "./hostname.ts"; const LOOPBACK_NO_PROXY = "localhost,127.0.0.1,[::1]"; @@ -189,6 +194,75 @@ describe("getHostname", () => { }); }); +describe("platformDefaultDockerHost", () => { + it("resolves the unix default off Windows", () => { + expect(platformDefaultDockerHost("darwin")).toBe("unix:///var/run/docker.sock"); + expect(platformDefaultDockerHost("linux")).toBe("unix:///var/run/docker.sock"); + }); + + it("resolves the named-pipe default on Windows", () => { + expect(platformDefaultDockerHost("win32")).toBe("npipe:////./pipe/docker_engine"); + }); +}); + +describe("resolveDockerDaemonEndpoint", () => { + let configDirs: Array = []; + + afterEach(() => { + for (const dir of configDirs) rmSync(dir, { recursive: true, force: true }); + configDirs = []; + }); + + function withDockerConfig( + options: Parameters[0], + env: Record, + run: () => T, + ): T { + const dir = writeDockerConfigDir(options); + configDirs.push(dir); + return withEnv( + { DOCKER_HOST: undefined, DOCKER_CONTEXT: undefined, DOCKER_CONFIG: dir, ...env }, + run, + ); + } + + it("returns DOCKER_HOST verbatim, non-tcp schemes included", () => { + expect( + withEnv({ DOCKER_HOST: "unix:///custom/engine.sock" }, resolveDockerDaemonEndpoint), + ).toBe("unix:///custom/engine.sock"); + expect(withEnv({ DOCKER_HOST: "tcp://docker-host:2375" }, resolveDockerDaemonEndpoint)).toBe( + "tcp://docker-host:2375", + ); + }); + + it("maps the default context to the platform-default daemon endpoint", () => { + expect(withDockerConfig({}, {}, resolveDockerDaemonEndpoint)).toBe(platformDefaultDockerHost()); + expect( + withDockerConfig( + { currentContext: "default", contexts: { default: "tcp://never-read:2375" } }, + {}, + resolveDockerDaemonEndpoint, + ), + ).toBe(platformDefaultDockerHost()); + }); + + it("returns the active context's stored endpoint verbatim", () => { + expect( + withDockerConfig( + { currentContext: "remote", contexts: { remote: "tcp://remote-host:2375" } }, + {}, + resolveDockerDaemonEndpoint, + ), + ).toBe("tcp://remote-host:2375"); + }); + + it("returns undefined for an unreadable non-default context, never the platform default", () => { + expect( + withDockerConfig({ currentContext: "ghost" }, {}, resolveDockerDaemonEndpoint), + ).toBeUndefined(); + }); +}); + describe("configureLoopbackProxyBypass", () => { it.each([ ["sets NO_PROXY when neither spelling is configured", {}, { NO_PROXY: LOOPBACK_NO_PROXY }], diff --git a/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts b/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts index 6a610e8aed..04fc9f26ad 100644 --- a/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts +++ b/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts @@ -13,6 +13,7 @@ import { pgDeltaNextEngineLayer } from "../commands/db/shared/pgdelta-engine.nex import { pgDeltaNextAdapterLayer } from "../commands/db/shared/pgdelta-next-adapter.layer.ts"; import { pgDeltaNextShadowLayer } from "../commands/db/shared/pgdelta-next-shadow.layer.ts"; import { declarativeSeamLayer } from "../commands/db/shared/pgdelta.seam.layer.ts"; +import { localDockerEngineLayer } from "./db-bootstrap/local-db-running.ts"; /** The in-process pg-delta engine — the only implementation. */ const pgDeltaEngineLayer = pgDeltaNextEngineLayer; @@ -43,11 +44,14 @@ export const migraRuntimeLayer = Layer.mergeAll( pgDeltaSslProbeLayer, ); const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer)); +const localDockerEngine = localDockerEngineLayer.pipe(Layer.provide(debugLoggerLayer)); const seam = declarativeSeamLayer.pipe( Layer.provide(pgDeltaCommandSettingsRuntimeLayer), Layer.provide(dbConnectionLayer), Layer.provide(dockerRunLayer), Layer.provide(httpClient), + // Backs the seam's `isLocalDbRunning`/`startLocalDatabase` Engine-API probe. + Layer.provide(localDockerEngine), ); const nextShadow = pgDeltaNextShadowLayer.pipe( Layer.provide(dockerRunLayer), @@ -71,4 +75,6 @@ export const pgDeltaCommandRuntimeLayer = Layer.mergeAll( seam, engine, pgDeltaCommandSettingsRuntimeLayer, + // Exposed for handlers' own direct `isLocalDbRunning` calls (`db diff --use-pgadmin`). + localDockerEngine, ); 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 d3739af49b..81afa7bc67 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -16,6 +16,7 @@ import { mockCommandSettings, mockDockerDaemonCliSpawner, mockLinkedProjectCacheTracked, + mockLocalDockerEngineUnavailableLayer, mockShadowContainerCliSpawner, mockTelemetryStateTracked, useShadowCacheDisabled, @@ -430,6 +431,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { docker, shadowDbConnection.layer, dockerDaemon?.layer ?? shadowSpawner.layer, + mockLocalDockerEngineUnavailableLayer, alwaysReadyHttpClientLayer, resolver, projectRefResolver, diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index 288947e3c8..d4a4e41ba3 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -30,19 +30,19 @@ removed `DeclarativeSeam.execInherit` seam — see those commands' own ## Files Read -| Path | Format | When | -| ----------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------- | -| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | -| `/supabase/config.toml` | TOML | always, parsed up front before any destructive work (embedded defaults when absent); re-read for local bucket seeding | -| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | always, resolved before the local prelude (config values, bootstrap config) | -| `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line | -| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | -| seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config | -| schema files from `[db.migrations].schema_paths` | SQL | when the `--experimental` schema-files branch is taken, either target (see Notes) | -| `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | -| `/supabase/roles.sql` | SQL | local PG15 path only, via the reused `startSetupLocalDatabase` pipeline — missing file tolerated | -| `~/.docker/config.json` | JSON | via the `docker`/`podman` CLI itself, for registry auth — never read directly by this process | +| Path | Format | When | +| -------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | +| `/supabase/config.toml` | TOML | always, parsed up front before any destructive work (embedded defaults when absent); re-read for local bucket seeding | +| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | always, resolved before the local prelude (config values, bootstrap config) | +| `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line | +| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | +| seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config | +| schema files from `[db.migrations].schema_paths` | SQL | when the `--experimental` schema-files branch is taken, either target (see Notes) | +| `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | +| `/supabase/roles.sql` | SQL | local PG15 path only, via the reused `startSetupLocalDatabase` pipeline — missing file tolerated | +| `~/.docker/config.json` + Docker context store (`contexts/meta//meta.json`) | JSON | resolving the daemon endpoint for the local path's running probe (in-process); also read by the `docker`/`podman` CLI itself for registry auth | ## Files Written @@ -60,7 +60,7 @@ equivalent, PG15) or `InitSchema14`/`ApplyApiPrivileges` (PG14). | Command | When | Purpose | | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `docker container inspect supabase_db_` | local path | `AssertSupabaseDbIsRunning` probe (Podman fallback) | +| `docker container inspect supabase_db_` | local path | `AssertSupabaseDbIsRunning` probe (Podman fallback) — spawned only when the direct Engine-API request over the active local socket/named pipe gives no definitive answer | | `docker container rm -f supabase_db_` / `docker volume rm -f ` | local path, PG15 | remove the existing container/volume before recreating (Podman fallback) | | `docker network create` / `docker volume create` / `docker create` / `docker start` | local path, PG15 | recreate the Postgres container (same primitives `db start` uses) | | `docker run --rm ` | local path, PG15, per enabled service | the one-shot `initSchema15` migrate jobs (`startSetupLocalDatabase`) | @@ -121,27 +121,29 @@ the whole reset** (not just "skip buckets"). ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| — | — | — | — | Connects to Postgres directly. The `--linked` resolver may call the Management API to mint a temporary login role; local bucket seeding calls the Storage gateway. | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------- | ---- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| — | — | — | — | Connects to Postgres directly. The `--linked` resolver may call the Management API to mint a temporary login role; local bucket seeding calls the Storage gateway. | +| GET | `/containers/supabase_db_/json` | — | — | local path — the running probe's Docker Engine API request over the active context's local socket/named pipe (`Api-Version`/`Server` identity headers + status; a 200 body must parse as a JSON object; identity-gated 200 → running, 404 → absent; anything else → the spawned-CLI fallback) | ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | -| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | -| `SUPABASE_EXPERIMENTAL` | selects the schema-files apply branch on either target | no (also `--experimental`) | -| `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` | overrides `[experimental.pgdelta].enabled`; a truthy value flips the reset gate (`experimental && resolvedVersion === "" && !toml.pgDelta.enabled`) back to timestamped migrations even with `--experimental` set — switches between two different destructive code paths | no | -| `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` | overrides `[db.migrations].schema_paths` (viper `AutomaticEnv`, beats the config-file value) for the schema-files apply branch — genuinely effective on both targets now | no (no dedicated flag — config-file-only otherwise) | -| `SUPABASE_PROJECT_ID` | overrides the local container id; ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the image registry used to resolve the local path's container images (scoped for the whole run via `applyProjectEnv`) | no (project `.env` or shell) | -| `SUPABASE_USE_SLIM_IMAGES` | resolves the local-reset Postgres image and the realtime/storage/auth migrate-job images from slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, and flag-off `15.8.1.085` stay on docker.io | no (ambient shell only) | -| `SUPABASE_DB_PORT` / `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | local-path container-recreate config overrides, same as `db start` | no | -| `SUPABASE_API_PORT` / `SUPABASE_API_EXTERNAL_URL` / `SUPABASE_API_TLS_*` / `SUPABASE_API_ENABLED` | local-path bucket-seed step: override the matching `[api]` fields for the Storage gateway URL/TLS, same as `seed buckets` (shell or project dotenv; #6452) | no | -| `SUPABASE_AUTH_JWT_SECRET` / `SUPABASE_AUTH_SERVICE_ROLE_KEY` | local path: override `auth.jwt_secret` / `auth.service_role_key` (shell or project dotenv, `encrypted:` decrypted) — feeds the recreated Postgres container (jwt secret), the fresh-database setup jobs (both) and the bucket-seed Storage service-role key, same as `seed buckets` | no | -| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the recreated container/network onto an existing Docker network | no | +| Variable | Purpose | Required? | +| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | local path: ambient shell environment only (project dotenv files deliberately never override Docker client keys) — resolves the daemon endpoint for the running probe (in-process) and steers the spawned `docker`/`podman` CLI itself | no | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | +| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | +| `SUPABASE_EXPERIMENTAL` | selects the schema-files apply branch on either target | no (also `--experimental`) | +| `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` | overrides `[experimental.pgdelta].enabled`; a truthy value flips the reset gate (`experimental && resolvedVersion === "" && !toml.pgDelta.enabled`) back to timestamped migrations even with `--experimental` set — switches between two different destructive code paths | no | +| `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` | overrides `[db.migrations].schema_paths` (viper `AutomaticEnv`, beats the config-file value) for the schema-files apply branch — genuinely effective on both targets now | no (no dedicated flag — config-file-only otherwise) | +| `SUPABASE_PROJECT_ID` | overrides the local container id; ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the image registry used to resolve the local path's container images (scoped for the whole run via `applyProjectEnv`) | no (project `.env` or shell) | +| `SUPABASE_USE_SLIM_IMAGES` | resolves the local-reset Postgres image and the realtime/storage/auth migrate-job images from slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, and flag-off `15.8.1.085` stay on docker.io | no (ambient shell only) | +| `SUPABASE_DB_PORT` / `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | local-path container-recreate config overrides, same as `db start` | no | +| `SUPABASE_API_PORT` / `SUPABASE_API_EXTERNAL_URL` / `SUPABASE_API_TLS_*` / `SUPABASE_API_ENABLED` | local-path bucket-seed step: override the matching `[api]` fields for the Storage gateway URL/TLS, same as `seed buckets` (shell or project dotenv; #6452) | no | +| `SUPABASE_AUTH_JWT_SECRET` / `SUPABASE_AUTH_SERVICE_ROLE_KEY` | local path: override `auth.jwt_secret` / `auth.service_role_key` (shell or project dotenv, `encrypted:` decrypted) — feeds the recreated Postgres container (jwt secret), the fresh-database setup jobs (both) and the bucket-seed Storage service-role key, same as `seed buckets` | no | +| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the recreated container/network onto an existing Docker network | no | ## Connection loss during migration apply diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index c3dd5515db..6e75c9ec57 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -19,6 +19,7 @@ import { VALID_REF, mockCommandSettings, mockLinkedProjectCacheTracked, + mockLocalDockerEngineUnavailableLayer, mockCommandPlatformApiService, mockTelemetryStateTracked, useTempWorkdir, @@ -44,6 +45,7 @@ import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; import { DbConfigConnectTempRoleError } from "../../../command-internal/db-config.errors.ts"; +import { LocalDockerEngine } from "../../../command-internal/db-bootstrap/local-db-running.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection, @@ -465,6 +467,7 @@ function setup( mockCommandSettings({ workdir }), BunServices.layer, child.layer, + mockLocalDockerEngineUnavailableLayer, mockRuntimeInfo({ platform: "linux" }), mockProcessControl().layer, alwaysReadyHttpClientLayer, @@ -661,6 +664,34 @@ describe("db reset", () => { }, ); + it.live( + "refuses a local reset from the direct Engine answer without touching the container CLI", + () => { + const { layer, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + routeOpts: { running: true }, + }); + return Effect.gen(function* () { + const exit = yield* dbReset(DEFAULT_FLAGS).pipe( + Effect.provide( + Layer.succeed(LocalDockerEngine, { + containerExists: () => Effect.succeed(Option.some(false)), + }), + ), + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("is not running."); + expect( + child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "inspect"), + ).toBe(false); + }); + }, + ); + it.live( "fails a local reset before the destructive recreate on a malformed config.toml", () => { diff --git a/apps/cli/src/commands/db/reset/reset.layers.ts b/apps/cli/src/commands/db/reset/reset.layers.ts index 28d1f12400..44d1585ceb 100644 --- a/apps/cli/src/commands/db/reset/reset.layers.ts +++ b/apps/cli/src/commands/db/reset/reset.layers.ts @@ -1,4 +1,5 @@ import { Layer } from "effect"; +import { localDockerEngineLayer } from "../../../command-internal/db-bootstrap/local-db-running.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { commandCredentialsLayer } from "../../../auth/command-credentials.layer.ts"; @@ -84,5 +85,7 @@ export const dbResetRuntimeLayer = Layer.mergeAll( stdinLayer, // Backs the native local recreate's PG15+ one-shot migrate jobs. dockerRunLayer, + // Backs `isLocalDbRunning`'s direct Engine-API probe (+ its `--debug` trace). + localDockerEngineLayer.pipe(Layer.provide(debugLoggerLayer)), commandRuntimeLayer(["db", "reset"]), ); diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index 8877e5035a..95177d7db9 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -23,6 +23,7 @@ import { import { mockCommandSettings, mockLinkedProjectCacheTracked, + mockLocalDockerEngineUnavailableLayer, mockCommandPlatformApiService, mockTelemetryStateTracked, useTempWorkdir, @@ -207,6 +208,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { cache.layer, seam, engine, + mockLocalDockerEngineUnavailableLayer, resolver, proxy, dbConn, diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index 95b10245c9..0d3f04bf6e 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -22,6 +22,7 @@ import { import { mockCommandSettings, mockLinkedProjectCacheTracked, + mockLocalDockerEngineUnavailableLayer, mockCommandPlatformApiService, mockTelemetryStateTracked, useShadowCacheDisabled, @@ -244,6 +245,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { cache.layer, seam, engine, + mockLocalDockerEngineUnavailableLayer, dbConn, resolver, mockCommandSettings({ workdir, projectId: opts.projectId ?? Option.some("test") }), diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts index e8cb0f617c..96f8d0ceea 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, vi } from "vitest"; import { mockCommandSettings, + mockLocalDockerEngineUnavailableLayer, mockShadowContainerCliSpawner, useShadowCacheDisabled, } from "../../../../tests/helpers/command-mocks.ts"; @@ -117,6 +118,7 @@ function setup( // `seam` itself resolves — `Layer.provide` fully resolves each requirement it can // satisfy as it's applied, so `BunServices.layer` only ever fills in `FileSystem`/`Path`. Layer.provide(shadowSpawner.layer), + Layer.provide(mockLocalDockerEngineUnavailableLayer), Layer.provide(BunServices.layer), ); diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts index 0c30f776d8..cd3aa13c94 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts @@ -88,6 +88,8 @@ export const declarativeSeamLayer = Layer.effect( cliSettings.workdir, Option.getOrUndefined(cliSettings.projectId), ).pipe( + // Satisfies the probe's `LocalDockerEngine` requirement from the captured deps. + Effect.provideContext(context), Effect.mapError( (cause) => new DeclarativeShadowDbError({ diff --git a/apps/cli/src/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/commands/db/start/SIDE_EFFECTS.md index 0414caeb8e..4e73105a01 100644 --- a/apps/cli/src/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/start/SIDE_EFFECTS.md @@ -7,9 +7,15 @@ Fully native. CLI-1954 removed the last Go delegation — the hidden `db __db-bo `Finished` line, no `--exclude`, no `--ignore-health-check`. The handler validates config, checks whether the local Postgres container is already -running (`isLocalDbRunning` — a native `docker container inspect`, hoisted to -`command-internal/db-bootstrap/local-db-running.ts` and shared with `db reset --local`'s -own running-check), and otherwise natively brings up the container itself, reusing +running (`isLocalDbRunning` in `command-internal/db-bootstrap/local-db-running.ts`, +shared with `db reset --local`'s own running-check — a direct Docker Engine API +`GET /containers//json` over the active context's unix socket / named pipe, falling +back to a spawned `docker container inspect` whenever the Engine gives no definitive, +Engine-identified answer, so a stalled `docker` CLI binary can no longer block the +already-running check itself — issue #6110's silent pre-output hang. The bring-up that +follows a definitive "absent" answer still shells out, starting with the volume-freshness +probe, so a stalled `docker` CLI still blocks an actual bring-up), and otherwise natively +brings up the container itself, reusing `command-internal/db-bootstrap/`'s container-bootstrap primitives (the same ones `supabase start` uses for its own Postgres bring-up, and `db reset --local`'s own recreate composition reuses too — see that command's `SIDE_EFFECTS.md`): @@ -74,7 +80,7 @@ volume was confirmed fresh this run). | `/supabase/migrations/*.sql`, `supabase/seed.sql` | SQL | on a fresh volume with no `--from-backup`, via the standard migration-apply + seed pipeline | | `/supabase/` (files/directories/globs) | SQL | on a fresh volume with no `--from-backup`, INSTEAD of `migrations/*.sql`, when `--experimental`/`SUPABASE_EXPERIMENTAL` is set and `[experimental.pgdelta] enabled` is false | | `/supabase/.branches/_current_branch` | text | always, existence check before writing (see "Files Written") | -| `~/.docker/config.json` | JSON | via the `docker`/`podman` CLI itself, for registry auth — never read directly by this process | +| `~/.docker/config.json` + Docker context store (`contexts/meta//meta.json`) | JSON | resolving the daemon endpoint for the already-running probe (in-process); also read by the `docker`/`podman` CLI itself for registry auth | ## Files Written @@ -88,11 +94,13 @@ volume was confirmed fresh this run). ## Subprocesses Every step below shells out to `docker` (falling back to `podman`), matching every other -native container command in this codebase — never `supabase-go`. +native container command in this codebase — never `supabase-go` — except the +already-running probe, which prefers a direct Engine-API request (see "API Routes") and +only spawns on fallback. | Command | When | | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `docker container inspect supabase_db_` | always — the already-running probe | +| `docker container inspect supabase_db_` | the already-running probe — only when the direct Engine-API request (active local socket/named pipe) gives no definitive answer | | `docker network create --label ... ` | when not already running, unless `--network-id` names a built-in network | | `docker volume inspect supabase_db_` | when not already running — the pre-create fresh-volume probe | | `docker image inspect` / `docker pull` (registry-fallback resolve) | when not already running — resolves the Postgres image | @@ -105,9 +113,12 @@ native container command in this codebase — never `supabase-go`. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +No platform (Management API) routes. The already-running probe issues one Docker Engine +API request over the active context's local unix socket / named pipe: + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/containers/supabase_db_/json` | — | — | `Api-Version`/`Server` identity headers + status; a 200 body must parse as a JSON object (identity-gated 200 → running, 404 → absent; anything else → the spawned-CLI fallback) | ## Environment Variables @@ -128,7 +139,7 @@ native container command in this codebase — never `supabase-go`. | `SUPABASE_AUTH_EXTERNAL_URL` / `SUPABASE_AUTH_SITE_URL` | auth migrate job env overrides | no | | `SUPABASE_AUTH_JWT_EXPIRY` | Postgres's `JWT_EXP` env / signing | no | | `SUPABASE_EXPERIMENTAL` (or `--experimental`) | fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` | no | -| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file, installed into the process environment before any Docker work) to pick the Docker daemon this whole command talks to | no | +| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read from the ambient shell environment to pick the Docker daemon this whole command talks to (project dotenv files deliberately never override Docker client keys) | no | | `SUPABASE_USE_SLIM_IMAGES` | resolves the current Dockerfile pin (and majors 13/15's published slim PG15 pin, `15.14.1.167`) and PG15+ realtime/storage/auth migrate-job images from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); historical `.temp` pins, PG14, OrioleDB, and flag-off majors 13/15 (`15.8.1.085`) stay on docker.io | no | `--network-id` (a global CLI flag, not an environment variable — `command-internal/global-flags.ts`) diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index 6fe67a9fd6..6db72c90bd 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -16,6 +16,7 @@ import { } from "../../../../tests/helpers/mocks.ts"; import { mockCommandSettings, + mockLocalDockerEngineUnavailableLayer, mockTelemetryStateTracked, useTempWorkdir, sequentialExecBatch, @@ -27,6 +28,7 @@ import { NetworkIdFlag, } from "../../../command-internal/global-flags.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; +import { LocalDockerEngine } from "../../../command-internal/db-bootstrap/local-db-running.ts"; import { DbConnectError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection, type DbSession } from "../../../command-internal/db-connection.service.ts"; import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts"; @@ -323,6 +325,7 @@ function setup(opts: SetupOpts = {}) { cliSettings, telemetry.layer, child.layer, + mockLocalDockerEngineUnavailableLayer, alwaysReadyHttpClientLayer, dbConnection, dockerRunLayer.pipe(Layer.provide(child.layer), Layer.provide(mockProcessControl().layer)), @@ -374,6 +377,25 @@ describe("db start", () => { }); }); + it.live( + "reports an already-running database from the direct Engine-API answer without touching the container CLI", + () => { + const { layer, out, child } = setup({}); + return Effect.gen(function* () { + yield* dbStart(DEFAULT_FLAGS).pipe( + Effect.provide( + Layer.succeed(LocalDockerEngine, { + containerExists: () => Effect.succeed(Option.some(true)), + }), + ), + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Postgres database is already running."); + expect(child.spawned).toEqual([]); + }); + }, + ); + it.live( "starts the database on a fresh volume: creates the container, runs the SetupLocalDatabase-equivalent pipeline, and writes _current_branch", () => { diff --git a/apps/cli/src/commands/db/start/start.layers.ts b/apps/cli/src/commands/db/start/start.layers.ts index 8a350c6f38..009249bc79 100644 --- a/apps/cli/src/commands/db/start/start.layers.ts +++ b/apps/cli/src/commands/db/start/start.layers.ts @@ -1,4 +1,5 @@ import { Layer } from "effect"; +import { localDockerEngineLayer } from "../../../command-internal/db-bootstrap/local-db-running.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { commandSettingsLayer } from "../../../config/command-settings.layer.ts"; @@ -33,6 +34,8 @@ const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer)); export const dbStartRuntimeLayer = Layer.mergeAll( cliSettings, telemetryStateLayer, + // Backs `isLocalDbRunning`'s direct Engine-API probe (+ its `--debug` trace). + localDockerEngineLayer.pipe(Layer.provide(debugLoggerLayer)), commandRuntimeLayer(["db", "start"]), dockerRunLayer, dbConnectionLayer, diff --git a/apps/cli/src/commands/start/services/vector.service.ts b/apps/cli/src/commands/start/services/vector.service.ts index b3bbeabc2a..2b181c5dbc 100644 --- a/apps/cli/src/commands/start/services/vector.service.ts +++ b/apps/cli/src/commands/start/services/vector.service.ts @@ -40,6 +40,7 @@ import { slimWgetWaitCommand, } from "../../../command-internal/db-bootstrap/slim-runtime.ts"; import { usesSlimImageRuntime } from "../../../shared/services/slim-images.ts"; +import { platformDefaultDockerHost } from "../../../command-internal/hostname.ts"; import { renderStartVectorYaml } from "../lib/template-render.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -114,14 +115,6 @@ export function shouldMountRootDockerSocket(host: string): boolean { ); } -/** - * The platform-default Docker host: `platform` defaults to - * `process.platform`. - */ -export function platformDefaultDockerHost(platform: NodeJS.Platform = process.platform): string { - return platform === "win32" ? "npipe:////./pipe/docker_engine" : "unix:///var/run/docker.sock"; -} - export interface VectorDockerSocketPlan { /** The `DOCKER_HOST` env override — empty for the `unix` scheme, which sets no override at all. */ readonly env: Readonly>; 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 e2b8d4d606..0f991a8666 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 @@ -7,7 +7,6 @@ import { buildVectorContainerSpec, buildVectorEntrypointScript, parseDockerHostUrl, - platformDefaultDockerHost, resolveDockerDaemonHost, resolveVectorDockerSocketPlan, shouldMountRootDockerSocket, @@ -146,17 +145,6 @@ describe("shouldMountRootDockerSocket", () => { }); }); -describe("platformDefaultDockerHost", () => { - test("resolves the unix default off Windows", () => { - expect(platformDefaultDockerHost("darwin")).toBe("unix:///var/run/docker.sock"); - expect(platformDefaultDockerHost("linux")).toBe("unix:///var/run/docker.sock"); - }); - - test("resolves the npipe default on Windows", () => { - expect(platformDefaultDockerHost("win32")).toBe("npipe:////./pipe/docker_engine"); - }); -}); - describe("resolveVectorDockerSocketPlan", () => { test("tcp: proxies through host.docker.internal on the daemon's own port, no binds/securityOpt (start.go:422-426)", () => { const plan = resolveVectorDockerSocketPlan("tcp://127.0.0.1:2376"); diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts index 7c81b0fe4c..88bf73cc78 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -46,6 +46,7 @@ import { PGDATA_PATH, } from "../../src/command-internal/db-bootstrap/pgdata-snapshot.ts"; import { projectRefLayer } from "../../src/config/project-ref.layer.ts"; +import { LocalDockerEngine } from "../../src/command-internal/db-bootstrap/local-db-running.ts"; import { LinkedProjectCache } from "../../src/telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../src/telemetry/telemetry-state.service.ts"; import { CliArgs } from "../../src/shared/cli/cli-args.service.ts"; @@ -84,6 +85,15 @@ export const mockLinkedProjectCacheLayer = Layer.succeed(LinkedProjectCache, { cache: () => Effect.void, }); +/** + * Hermetic default: the Engine probe never answers, so tests exercise their + * mocked container-CLI spawner instead of dialing the machine's real Docker + * socket. Direct-path tests provide their own definitive `LocalDockerEngine`. + */ +export const mockLocalDockerEngineUnavailableLayer = Layer.succeed(LocalDockerEngine, { + containerExists: () => Effect.succeed(Option.none()), +}); + export const mockTelemetryStateLayer = Layer.succeed(TelemetryState, { flush: Effect.void, stitchLogin: () => Effect.void,