diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 9845b95485..6f2b87e03a 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -27,7 +27,9 @@ when its engine answers, and otherwise native on Linux x64/arm64 and macOS arm64 platforms without a reachable engine, the command fails and asks you to start Docker or Podman. The selected runtime is saved with the stack and reused without probing; when auto selection skips Docker, the command prints a notice saying so. To switch, destroy the stack or choose a different -`--stack` name. Project stacks created by database commands, and shadow stacks +`--stack` name. When an explicit `--runtime docker` or a saved Docker stack cannot reach Docker, +the failure asks you to install or start it, and also suggests `--runtime native` for a new stack +on platforms that support native. Project stacks created by database commands, and shadow stacks created without a project stack, use the same selection. `supabase stack prepare` downloads or pulls artifacts for the selected stack without starting diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index da985e7a39..571b2b3a7f 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -41,8 +41,11 @@ probe is bounded by 10 seconds. Without a reachable engine on other platforms, t asks the user to start Docker or Podman. When auto selection skips Docker, an info line names the saved Podman or native runtime and how to switch to Docker. An existing stack keeps its saved runtime and runs no probe. Explicit `--runtime docker`, `podman`, or `native` has no fallback. +When an explicit or saved Docker runtime is unreachable, the reported failure suggests starting +Docker, and `--runtime native` for a new stack on platforms that support native. + Native startup refuses root because PostgreSQL `initdb` cannot run as root, unless a Claude Code -sandbox is detected or `SUPABASE_NATIVE_POSTGRES_USER` names a non-root user. PostgreSQL then runs +or Modal Sandbox is detected or `SUPABASE_NATIVE_POSTGRES_USER` names a non-root user. PostgreSQL then runs as that user: the CLI chowns the instance data, root key, socket directory, and the cached bundle's `pgsodium_getkey.sh` to it, and adds traverse-only `o+x` to their parent directories, including root's home directory. Later commands that restrict the artifact cache and stack state roots to diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 5a6053a0da..e590ff0572 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -1,9 +1,11 @@ +import { defaultRuntime } from "@supabase/stack/internal/artifacts"; import { endpointReports } from "../stack-endpoints.format.ts"; import { withProjectFunctionsEnv } from "../../../../command-internal/stack-functions-env.ts"; import { automaticRuntimeNotice, selectStackRuntime, } from "../../../../command-internal/stack-runtime.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { Effect, FileSystem, Fiber, Option, Path, Redacted, Ref } from "effect"; import { resolveNativePostgresUser, @@ -95,6 +97,35 @@ const stackError = ( }); }; +// A saved stack keeps its runtime, so only a new stack can switch to native. +const dockerUnavailableSuggestion = ( + runtimeInfo: { readonly platform: string; readonly arch: string }, + creating: boolean, +) => + creating && defaultRuntime({ os: runtimeInfo.platform, arch: runtimeInfo.arch }) === "native" + ? "Docker CLI or daemon isn't reachable. Install or start Docker, or run with --runtime native." + : "Docker CLI or daemon isn't reachable. Install or start Docker."; + +const stackAcquireError = ( + cause: StackError, + runtimeContext: { + readonly selectedRuntime: "native" | "docker" | "podman"; + readonly runtime: { readonly platform: string; readonly arch: string }; + readonly creating: boolean; + }, +) => { + const base = stackError(cause); + if (cause.reason !== "runtime-unavailable" || runtimeContext.selectedRuntime !== "docker") + return base; + return new StackCommandStartError({ + reason: "runtime", + message: base.message, + ...(base.detail === undefined ? {} : { detail: base.detail }), + suggestion: dockerUnavailableSuggestion(runtimeContext.runtime, runtimeContext.creating), + cause: base.cause, + }); +}; + const loadStartConfig = (projectRoot: string, fs: FileSystem.FileSystem, path: Path.Path) => Effect.gen(function* () { const config = yield* loadStackConfig(projectRoot); @@ -193,6 +224,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags runtime: flags.runtime, }) .pipe(Effect.mapError(mapTargetError)); + const runtime = yield* RuntimeInfo; const selectedRuntime = yield* selectStackRuntime(target.runtime).pipe( Effect.mapError( (error) => @@ -247,7 +279,11 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags ), ), ), - ).pipe(Effect.mapError(stackError)); + ).pipe( + Effect.mapError((cause) => + stackAcquireError(cause, { selectedRuntime, runtime, creating: target.id === undefined }), + ), + ); const runtimeNotice = target.id === undefined ? automaticRuntimeNotice(target.runtime, selectedRuntime) : undefined; if (runtimeNotice !== undefined) yield* output.info(runtimeNotice); diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index e07978b65a..0e9a760b1b 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -1107,6 +1107,11 @@ describe("experimental stack start", () => { Effect.provide(Layer.mergeAll(layers(root, fakeStack(), output, false), target, api)), ); expect(error.message).toContain("Cannot connect to the Docker daemon"); + expect(error).toBeInstanceOf(StackCommandStartError); + if (error instanceof StackCommandStartError) { + expect(error.reason).toBe("runtime"); + expect(error.suggestion).toContain("Docker CLI or daemon isn't reachable"); + } expect(output.stderrText).not.toContain("Failed to stop"); const stacks = yield* StackApi.pipe( diff --git a/docs/adr/0026-slim-artifact-mirrors.md b/docs/adr/0026-slim-artifact-mirrors.md index 31b429dcc1..cdcc4fb4ca 100644 --- a/docs/adr/0026-slim-artifact-mirrors.md +++ b/docs/adr/0026-slim-artifact-mirrors.md @@ -19,7 +19,7 @@ ECR Public tags are always mutable; `ecr-public create-repository` has no immuta A public S3 bucket on `*.amazonaws.com` holds native archives for hosts that cannot reach GitHub Releases ([infra/cli-artifacts](../../infra/cli-artifacts/README.md)). It is not a checksum authority. The stack tries the GitHub Release first and falls back to that bucket. The expected checksum comes from the release `SHA256SUMS` or, when that is blocked, from the archive layer of the `:version-native-` artifact on GHCR. An archive from either host is accepted only when it matches. -The container runtime pulls a catalog image from GHCR first and falls back to the same reference on ECR Public. A failing fallback, for an image or a native archive, still reports the primary's original error. +The container runtime pulls a catalog image from GHCR first and falls back to the same reference on ECR Public. A failing image fallback still reports the primary's original error. When every native checksum source or archive mirror fails, the error names each source with its failure. ## Follow-up diff --git a/packages/stack/README.md b/packages/stack/README.md index 13d5880023..4a2f2fa983 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -85,7 +85,7 @@ Omitted database `jwtSecret` and `rootKey` inputs use the shared local-developme as `DEFAULT_LOCAL_JWT_SECRET` and `DEFAULT_POSTGRES_ROOT_KEY`. Explicit values override these defaults. The effective root key is supplied through a stack-owned file for both native and container runtimes. -Native PostgreSQL refuses to run as uid 0. When the stack runs as root inside a detected agent sandbox (Claude Code), or `SUPABASE_NATIVE_POSTGRES_USER=` names a non-root system user, only the PostgreSQL process runs as that user: the instance data directory, root key file, socket directory, and the bundle's `pgsodium_getkey.sh` are chowned to it, and the instance directory, the PostgreSQL bundle directory, and their ancestors receive traverse-only (`o+x`) permission, which the artifact cache and stack state keep when they restrict their roots to the owner. Running as root elsewhere fails before PostgreSQL launches. +Native PostgreSQL refuses to run as uid 0. When the stack runs as root inside a detected agent sandbox (Claude Code, Modal Sandbox), or `SUPABASE_NATIVE_POSTGRES_USER=` names a non-root system user, only the PostgreSQL process runs as that user: the instance data directory, root key file, socket directory, and the bundle's `pgsodium_getkey.sh` are chowned to it, and the instance directory, the PostgreSQL bundle directory, and their ancestors receive traverse-only (`o+x`) permission, which the artifact cache and stack state keep when they restrict their roots to the owner. Running as root elsewhere fails before PostgreSQL launches. Native PostgreSQL listens only on its socket and reads a stack-generated HBA file, written to the socket directory on every launch, instead of `PGDATA/pg_hba.conf`. It trusts `supabase_admin`, including through the proxied loopback database port, and requires `scram-sha-256` passwords from every other role. diff --git a/packages/stack/src/HostProcess.ts b/packages/stack/src/HostProcess.ts index 3ff1146085..46b60d3820 100644 --- a/packages/stack/src/HostProcess.ts +++ b/packages/stack/src/HostProcess.ts @@ -92,6 +92,7 @@ type HostFailureReason = | "runtime-unavailable" | "invalid-owner-pid" | "owner-exit-pending" + | "owner-exit-zombie" | "owner-exit-probe"; const HostIdentity = Schema.Struct({ projectRoot: Schema.String, @@ -572,37 +573,62 @@ export const launchHost = Effect.fn("HostProcess.launchHost")(function* ( export type OwnerExitProbeResult = | { readonly state: "absent" } | { readonly state: "present" } + | { readonly state: "zombie" } | { readonly state: "inconclusive"; readonly code: "EPERM" }; export type OwnerExitProbe = (pid: number) => Effect.Effect; -/** Probes the captured owner PID with signal 0. */ -const probeOwnerExit: OwnerExitProbe = Effect.fn("HostProcess.probeOwnerExit")(function* (pid) { - return yield* Effect.try({ - try: () => { - process.kill(pid, 0); - return { state: "present" } as const; - }, - catch: (cause) => - new HostProcessError({ - operation: "shutdown-exit", - message: `Shutdown acknowledged, but probing owner process ${pid} failed: ${String(cause)}`, - reason: "owner-exit-probe", - cause, +const zombieProcStates = new Set(["Z", "X"]); + +/** The state field follows the last `)`, because `comm` may itself contain spaces and parens. */ +const procStatState = (stat: string): string | undefined => + stat + .slice(stat.lastIndexOf(")") + 1) + .trim() + .split(/\s+/u)[0]; + +/** Probes the owner PID with signal 0 and, on Linux, reports a `Z` or `X` owner as a zombie. */ +export const ownerExitProbe = ( + fs: FileSystem.FileSystem, + platform: string = process.platform, +): OwnerExitProbe => + Effect.fn("HostProcess.probeOwnerExit")(function* (pid) { + const signalResult = yield* Effect.try({ + try: () => { + process.kill(pid, 0); + return { state: "present" } as const; + }, + catch: (cause) => + new HostProcessError({ + operation: "shutdown-exit", + message: `Shutdown acknowledged, but probing owner process ${pid} failed: ${String(cause)}`, + reason: "owner-exit-probe", + cause, + }), + }).pipe( + Effect.catch((failure): Effect.Effect => { + const code = causeCode(failure.cause); + if (code === "ESRCH") return Effect.succeed({ state: "absent" } as const); + if (code === "EPERM") return Effect.succeed({ state: "inconclusive", code } as const); + return Effect.fail(failure); }), - }).pipe( - Effect.catch((failure): Effect.Effect => { - const code = causeCode(failure.cause); - if (code === "ESRCH") return Effect.succeed({ state: "absent" } as const); - if (code === "EPERM") return Effect.succeed({ state: "inconclusive", code } as const); - return Effect.fail(failure); - }), - ); -}); + ); + if (signalResult.state !== "present" || platform !== "linux") return signalResult; + const state = yield* fs.readFileString(`/proc/${pid}/stat`).pipe( + Effect.map(procStatState), + Effect.orElseSucceed(() => undefined), + ); + return state !== undefined && zombieProcStates.has(state) + ? ({ state: "zombie" } as const) + : signalResult; + }); -/** Waits until the captured owner PID is absent from the process table. */ +/** + * Waits until the captured owner PID is absent from the process table. An owner still a zombie when + * the wait ends has exited but was never reaped, as under a sandbox PID 1 that does not reap orphans. + */ export const waitForOwnerExit = Effect.fn("HostProcess.waitForOwnerExit")(function* ( pid: number, - probe: OwnerExitProbe = probeOwnerExit, + probe: OwnerExitProbe, ) { if (!Number.isSafeInteger(pid) || pid <= 0) return yield* error( @@ -611,25 +637,43 @@ export const waitForOwnerExit = Effect.fn("HostProcess.waitForOwnerExit")(functi "invalid-owner-pid", ); const check = probe(pid).pipe( - Effect.flatMap((result) => - result.state === "absent" - ? Effect.void - : Effect.fail( + Effect.flatMap((result) => { + switch (result.state) { + case "absent": + return Effect.void; + case "zombie": + return Effect.fail( + error("shutdown-exit", `Owner process ${pid} is not reaped yet`, "owner-exit-zombie"), + ); + case "present": + return Effect.fail( error( "shutdown-exit", - result.state === "present" - ? `Owner shutdown acknowledgement completed, but process ${pid} is still running` - : `Owner shutdown acknowledgement completed, but process ${pid} is inaccessible (${result.code}); exit is inconclusive`, + `Owner shutdown acknowledgement completed, but process ${pid} is still running`, "owner-exit-pending", ), - ), - ), + ); + case "inconclusive": + return Effect.fail( + error( + "shutdown-exit", + `Owner shutdown acknowledgement completed, but process ${pid} is inaccessible (${result.code}); exit is inconclusive`, + "owner-exit-pending", + ), + ); + } + }), ); return yield* check.pipe( Effect.retry({ schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "5 seconds" })), - while: (failure) => failure.reason === "owner-exit-pending", + while: (failure) => + failure.reason === "owner-exit-pending" || failure.reason === "owner-exit-zombie", }), + Effect.catchIf( + (failure) => failure.reason === "owner-exit-zombie", + () => Effect.void, + ), ); }); diff --git a/packages/stack/src/HostProcess.unit.test.ts b/packages/stack/src/HostProcess.unit.test.ts index 96a8c78067..6ac07fd48e 100644 --- a/packages/stack/src/HostProcess.unit.test.ts +++ b/packages/stack/src/HostProcess.unit.test.ts @@ -1,10 +1,11 @@ import { expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Ref } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, PlatformError, Ref } from "effect"; import * as TestClock from "effect/testing/TestClock"; import { HostProcessError, type OwnerExitProbe, type OwnerExitProbeResult, + ownerExitProbe, waitForOwnerExit, } from "./HostProcess.ts"; @@ -119,3 +120,66 @@ it.effect("rejects an invalid owner PID before probing", () => expect(yield* Ref.get(attempts)).toBe(0); }), ); + +// The test process itself answers signal 0, so /proc content alone decides the result. +const statOf = (stat: string) => + FileSystem.makeNoop({ readFileString: () => Effect.succeed(stat) }); + +it.effect("reports a zombie or dead owner on Linux", () => + Effect.gen(function* () { + const probe = (stat: string) => ownerExitProbe(statOf(stat), "linux")(process.pid); + const pid = process.pid; + expect(yield* probe(`${pid} (node) Z 1 1 1 0 -1 4194560`)).toEqual({ state: "zombie" }); + expect(yield* probe(`${pid} (node) X 1 1 1 0 -1 4194560`)).toEqual({ state: "zombie" }); + expect(yield* probe(`${pid} (my ) weird) proc) Z 1 1 1 0 -1 4194304`)).toEqual({ + state: "zombie", + }); + expect(yield* probe(`${pid} (node) S 1 1 1 0 -1 4194560`)).toEqual({ state: "present" }); + }), +); + +it.effect("waits for a zombie owner to be reaped", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0); + const probe: OwnerExitProbe = () => + Ref.getAndUpdate(attempts, (count) => count + 1).pipe( + Effect.map((count): OwnerExitProbeResult => + count < 2 ? { state: "zombie" } : { state: "absent" }, + ), + ); + const fiber = yield* waitForOwnerExit(123, probe).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("100 millis"); + yield* Fiber.join(fiber); + expect(yield* Ref.get(attempts)).toBe(3); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect("accepts an owner that is still an unreaped zombie when the wait ends", () => + Effect.gen(function* () { + const probe: OwnerExitProbe = () => Effect.succeed({ state: "zombie" }); + const fiber = yield* waitForOwnerExit(123, probe).pipe(Effect.exit, Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("6 seconds"); + expect(Exit.isSuccess(yield* Fiber.join(fiber))).toBe(true); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect("keeps the signal result when /proc is unreadable or the platform is not Linux", () => + Effect.gen(function* () { + const unreadable = FileSystem.makeNoop({ + readFileString: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: path, + }), + ), + }); + expect(yield* ownerExitProbe(unreadable, "linux")(process.pid)).toEqual({ state: "present" }); + const zombie = statOf(`${process.pid} (node) Z 1 1 1 0 -1 4194560`); + expect(yield* ownerExitProbe(zombie, "darwin")(process.pid)).toEqual({ state: "present" }); + }), +); diff --git a/packages/stack/src/Orchestrator.ts b/packages/stack/src/Orchestrator.ts index 839d2d0af1..4c136d4269 100644 --- a/packages/stack/src/Orchestrator.ts +++ b/packages/stack/src/Orchestrator.ts @@ -21,6 +21,7 @@ import { type ServiceObservation, } from "./Service.ts"; import type { ServiceAdmission } from "./Service.ts"; +import { errorChainMessage } from "./internal/error-message.ts"; export type OrchestratorOperation = | "start" @@ -47,21 +48,7 @@ export class OrchestratorError extends Data.TaggedError("OrchestratorError")<{ /** Summarizes a failure cause as one line per error, including its nested error causes. */ export const causeMessage = (cause: Cause.Cause): string => - Cause.prettyErrors(cause) - .map((error) => { - const parts: Array = []; - const visited = new Set(); - let current: unknown = error; - while (current instanceof Error && !visited.has(current)) { - visited.add(current); - const text = current.message || current.name; - // Wrappers often copy their cause's message; keep the chain but not the repeat. - if (parts.at(-1) !== text) parts.push(text); - current = current.cause; - } - return parts.join(": "); - }) - .join("; ") || "interrupted"; + Cause.prettyErrors(cause).map(errorChainMessage).join("; ") || "interrupted"; type CoreObservation = ServiceObservation; diff --git a/packages/stack/src/Rpc.ts b/packages/stack/src/Rpc.ts index 6e851424af..9b296509c4 100644 --- a/packages/stack/src/Rpc.ts +++ b/packages/stack/src/Rpc.ts @@ -19,10 +19,13 @@ export class StackError extends Schema.TaggedError()("StackError", { message: Schema.String, outcomes: Schema.optionalKey(Schema.Array(Outcome)), /** - * Why the client could not use an owner: none serves the stack (`owner-unavailable`), or one of - * another release does (`release-mismatch`). + * Why the client could not use an owner: none serves the stack (`owner-unavailable`), one of + * another release does (`release-mismatch`), or its container engine is unreachable + * (`runtime-unavailable`). */ - reason: Schema.optionalKey(Schema.Literals(["owner-unavailable", "release-mismatch"])), + reason: Schema.optionalKey( + Schema.Literals(["owner-unavailable", "release-mismatch", "runtime-unavailable"]), + ), }) {} /** Maps an owner failure to the RPC error, preserving per-member composition outcomes. */ diff --git a/packages/stack/src/StackHost.container-shutdown.integration.test.ts b/packages/stack/src/StackHost.container-shutdown.integration.test.ts index 9c57d139c5..4c0971f97c 100644 --- a/packages/stack/src/StackHost.container-shutdown.integration.test.ts +++ b/packages/stack/src/StackHost.container-shutdown.integration.test.ts @@ -3,7 +3,13 @@ import { expect, it } from "@effect/vitest"; import { Context, Crypto, Effect, FileSystem, Layer, Path, Redacted, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as State from "./State.ts"; -import { hasReason, launchHost, ownerClient, waitForOwnerExit } from "./HostProcess.ts"; +import { + hasReason, + launchHost, + ownerClient, + ownerExitProbe, + waitForOwnerExit, +} from "./HostProcess.ts"; import { makeContainerRuntime } from "./runtime/Container.ts"; import { makeDockerDatabaseRoot } from "../tests/docker-fixture.ts"; import { shutdownOwner } from "../tests/owner.ts"; @@ -119,7 +125,7 @@ it.live.skipIf(process.platform === "win32")( const signalAndWait = (endpoint: { pid: number }, signal: NodeJS.Signals) => Effect.sync(() => process.kill(endpoint.pid, signal)).pipe( Effect.andThen( - waitForOwnerExit(endpoint.pid).pipe( + waitForOwnerExit(endpoint.pid, ownerExitProbe(fs)).pipe( Effect.retry({ while: hasReason("owner-exit-pending") }), Effect.timeout("30 seconds"), ), @@ -226,7 +232,9 @@ it.live.skipIf(process.platform === "win32")( activeA = endpointA2; stoppedA = false; yield* shutdownOwner(accessA2, true); - yield* waitForOwnerExit(endpointA2.pid).pipe(Effect.timeout("15 seconds")); + yield* waitForOwnerExit(endpointA2.pid, ownerExitProbe(fs)).pipe( + Effect.timeout("15 seconds"), + ); stoppedA = true; activeA = undefined; expect(yield* containers(stackId, dataA), "destroy removes A containers").toEqual([]); @@ -237,7 +245,9 @@ it.live.skipIf(process.platform === "win32")( activeB = endpointB2; stoppedB = false; yield* shutdownOwner(accessB2, true); - yield* waitForOwnerExit(endpointB2.pid).pipe(Effect.timeout("15 seconds")); + yield* waitForOwnerExit(endpointB2.pid, ownerExitProbe(fs)).pipe( + Effect.timeout("15 seconds"), + ); stoppedB = true; activeB = undefined; expect(yield* containers(stackId, dataB), "destroy removes B containers").toEqual([]); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index c9642732f7..f8cb04d449 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -27,6 +27,7 @@ import { launchHost, observeHost, ownerClient, + ownerExitProbe, shutdownHost, waitForOwnerExit, type HostAccess, @@ -128,7 +129,9 @@ const failure = (operation: string, cause: unknown): StackError => ? { reason: "owner-unavailable" as const } : hasReason("release-mismatch")(cause) ? { reason: "release-mismatch" as const } - : {}), + : hasReason("runtime-unavailable")(cause) + ? { reason: "runtime-unavailable" as const } + : {}), }); type Kind = ServiceCreation["service"]; @@ -391,6 +394,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( >(), ); const crypto = yield* Crypto.Crypto; + const fs = yield* FileSystem.FileSystem; const handleScope = yield* Scope.Scope; const session = saved.lifetime === "session"; const launchOptions = { ...locations, stackId: saved.id, lifeline: session }; @@ -592,7 +596,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( onSome: (cause) => failure(operation, cause), }); if (!destroy) return yield* shutdownFailure; - const exitResult = yield* waitForOwnerExit(endpoint.pid).pipe( + const exitResult = yield* waitForOwnerExit(endpoint.pid, ownerExitProbe(fs)).pipe( Effect.mapError((cause) => failure("shutdown-exit", cause)), Effect.exit, ); @@ -611,7 +615,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( } return yield* shutdownFailure; } - yield* waitForOwnerExit(endpoint.pid).pipe( + yield* waitForOwnerExit(endpoint.pid, ownerExitProbe(fs)).pipe( Effect.mapError((cause) => failure("shutdown-exit", cause)), ); return { runtimeCleanup: "complete" } as const; diff --git a/packages/stack/src/internal/error-message.ts b/packages/stack/src/internal/error-message.ts new file mode 100644 index 0000000000..f770b806d5 --- /dev/null +++ b/packages/stack/src/internal/error-message.ts @@ -0,0 +1,14 @@ +/** Walks an error's `.cause` chain, joining each distinct message so nested detail is not lost. */ +export const errorChainMessage = (error: unknown): string => { + const parts: Array = []; + const visited = new Set(); + let current: unknown = error; + while (current instanceof Error && !visited.has(current)) { + visited.add(current); + const text = current.message || current.name; + // Wrappers often copy their cause's message; keep the chain but not the repeat. + if (parts.at(-1) !== text) parts.push(text); + current = current.cause; + } + return parts.join(": "); +}; diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts index 90b57330a5..f4453347b6 100644 --- a/packages/stack/src/preparation/SlimServicesSource.ts +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -4,6 +4,7 @@ import { HttpClient, HttpClientError } from "effect/unstable/http"; import { createZstdDecompress } from "node:zlib"; import { createHash } from "node:crypto"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { errorChainMessage } from "../internal/error-message.ts"; import type { ArtifactRequest, ArtifactSource } from "./ArtifactStore.ts"; import { PreparationError } from "./Errors.ts"; @@ -34,7 +35,7 @@ export interface SlimServicesArtifact { readonly target: "darwin-arm64" | "linux-amd64" | "linux-arm64"; readonly archive: "tar.zst"; readonly assetName: string; - /** Checksum authorities, tried in order. Download mirrors never supply their own checksum. */ + /** Checksum authorities, tried in order; a mirror's own checksum file counts only when listed here. */ readonly checksums: readonly [ SlimServicesChecksumSource, ...ReadonlyArray, @@ -224,41 +225,53 @@ const downloadToFile = Effect.fn("SlimServicesSource.downloadToFile")(function* }); }); -const fallbackDetail = (error: PreparationError): string => { - const nested = error.cause instanceof PreparationError ? error.cause.message : undefined; - return nested === undefined || nested.length === 0 - ? error.message - : `${error.message} (${nested})`; -}; - /** * Runs `attempt` against each candidate until one succeeds. When every candidate fails, the - * returned error is still the primary's. A fallback failure, including a checksum mismatch, is - * logged as a warning, and a fallback that succeeds names the host it used. + * returned error names each attempted source with its failure detail (the sole source's own + * error when there was nothing to aggregate). A fallback failure, including a checksum mismatch, + * is logged as a warning, and a fallback that succeeds names the host it used. */ const firstSuccess = ( + label: string, + artifact: Pick, candidates: readonly [T, ...ReadonlyArray], describe: (candidate: T) => string, attempt: (candidate: T) => Effect.Effect, ): Effect.Effect => { const [primary, ...fallbacks] = candidates; const fallback = ( - primaryError: PreparationError, + failures: ReadonlyArray, remaining: ReadonlyArray, ): Effect.Effect => { const [candidate, ...rest] = remaining; - if (candidate === undefined) return Effect.fail(primaryError); + if (candidate === undefined) { + const [first] = failures; + if (failures.length === 1 && first !== undefined) return Effect.fail(first[1]); + const detail = failures + .map(([failed, error]) => `${describe(failed)} (${errorChainMessage(error)})`) + .join("; "); + // The message already carries every failure, so a cause would repeat it in chain renderers. + return Effect.fail( + new PreparationError({ + message: `${label}: ${detail}`, + service: artifact.service, + version: artifact.version, + }), + ); + } return attempt(candidate).pipe( Effect.tap(() => Effect.logInfo(`Slim-services used ${describe(candidate)}`)), Effect.tapError((cause) => Effect.logWarning( - `Slim-services fallback ${describe(candidate)} failed: ${fallbackDetail(cause)}`, + `Slim-services fallback ${describe(candidate)} failed: ${errorChainMessage(cause)}`, ), ), - Effect.catch(() => fallback(primaryError, rest)), + Effect.catch((cause) => fallback([...failures, [candidate, cause] as const], rest)), ); }; - return attempt(primary).pipe(Effect.catch((primaryError) => fallback(primaryError, fallbacks))); + return attempt(primary).pipe( + Effect.catch((primaryError) => fallback([[primary, primaryError]], fallbacks)), + ); }; const checksumFor = (contents: string, archiveName: string): string | undefined => @@ -322,24 +335,29 @@ export const slimServicesChecksum = Effect.fn("SlimServicesSource.checksum")(fun artifact: SlimServicesArtifact, backoff: Schedule.Schedule = transferBackoff, ) { - return yield* firstSuccess(artifact.checksums, describeChecksumSource, (source) => - source.kind === "oci" - ? ociArchiveDigest(source, backoff) - : fetchBytes(source.url, backoff).pipe( - Effect.map((bytes) => new TextDecoder().decode(bytes)), - Effect.flatMap((contents) => { - const checksum = checksumFor(contents, `${artifact.assetName}.tar.zst`); - return checksum === undefined || !/^[a-f0-9]{64}$/iu.test(checksum) - ? Effect.fail( - new PreparationError({ - message: "Slim-services checksum is missing", - service: artifact.service, - version: artifact.version, - }), - ) - : Effect.succeed(checksum.toLowerCase()); - }), - ), + return yield* firstSuccess( + "Unable to resolve the slim-services checksum", + artifact, + artifact.checksums, + describeChecksumSource, + (source) => + source.kind === "oci" + ? ociArchiveDigest(source, backoff) + : fetchBytes(source.url, backoff).pipe( + Effect.map((bytes) => new TextDecoder().decode(bytes)), + Effect.flatMap((contents) => { + const checksum = checksumFor(contents, `${artifact.assetName}.tar.zst`); + return checksum === undefined || !/^[a-f0-9]{64}$/iu.test(checksum) + ? Effect.fail( + new PreparationError({ + message: "Slim-services checksum is missing", + service: artifact.service, + version: artifact.version, + }), + ) + : Effect.succeed(checksum.toLowerCase()); + }), + ), ); }); @@ -496,6 +514,8 @@ export const makeSlimServicesSource = ( return yield* Effect.gen(function* () { const artifact = yield* resolveArtifact(request); yield* firstSuccess( + "Unable to download the slim-services archive", + artifact, artifact.mirrors, (mirror) => mirror.downloadUrl, (mirror) => diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts index 467c31b253..9423210184 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -377,7 +377,7 @@ describe("slim-services artifact source", () => { ), ); - it.live("reports the primary checksum failure when every checksum source fails", () => + it.live("names every failed checksum source in the aggregated error", () => Effect.gen(function* () { const mirrored: SlimServicesArtifact = { ...artifact, @@ -391,7 +391,16 @@ describe("slim-services artifact source", () => { requested.push(requestUrl(input)); return Promise.resolve(new Response("", { status: 403 })); }, slimServicesChecksum(mirrored, immediate).pipe(Effect.exit)); - expect(errorOf(failed)?.message).toBe("Unable to download https://release.test/SHA256SUMS"); + expect(errorOf(failed)?.message).toBe( + "Unable to resolve the slim-services checksum: https://release.test/SHA256SUMS " + + "(Unable to download https://release.test/SHA256SUMS: HTTP 403); " + + "registry.test/supabase/cli/demo:v1.0.0-native-linux-amd64 (Unable to download " + + "https://registry.test/token?scope=repository:supabase/cli/demo:pull&service=registry.test: HTTP 403)", + ); + expect(errorOf(failed)).toMatchObject({ + service: artifact.service, + version: artifact.version, + }); expect(requested).toEqual([ "https://release.test/SHA256SUMS", "https://registry.test/token?scope=repository:supabase/cli/demo:pull&service=registry.test", @@ -445,7 +454,10 @@ describe("slim-services artifact source", () => { .materialize(request, rejected, expected) .pipe(Effect.exit), ); - expect(errorOf(failed)?.message).toBe("Unable to download slim-services archive"); + const message = errorOf(failed)?.message; + expect(message).toContain("Unable to download the slim-services archive"); + expect(message).toContain("https://release.test/demo.tar.zst"); + expect(message).toContain("https://bucket.test/bad.tar.zst"); expect(yield* fs.exists(`${rejected}/bin/demo`)).toBe(false); }).pipe(Effect.provide(NodeServices.layer)), ), diff --git a/packages/stack/src/runtime/postgres-user.ts b/packages/stack/src/runtime/postgres-user.ts index b8019c81c9..975a8e1c87 100644 --- a/packages/stack/src/runtime/postgres-user.ts +++ b/packages/stack/src/runtime/postgres-user.ts @@ -21,15 +21,23 @@ const sandboxes = [ detect: (env: Environment) => (env["CLAUDECODE"] ?? "") !== "" && env["IS_SANDBOX"] === "yes", preferredUsers: ["ubuntu"], }, + { + // Set only in Modal Sandboxes, not Functions; images have no fixed account, so rely on the scan. + name: "Modal Sandbox", + detect: (env: Environment) => (env["MODAL_SANDBOX_ID"] ?? "") !== "", + preferredUsers: [], + }, ]; -const environmentNames = [NATIVE_POSTGRES_USER_ENV, "CLAUDECODE", "IS_SANDBOX"]; +const environmentNames = [NATIVE_POSTGRES_USER_ENV, "CLAUDECODE", "IS_SANDBOX", "MODAL_SANDBOX_ID"]; export type NativePostgresUser = | { readonly _tag: "NotNeeded" } | { readonly _tag: "StepDown"; readonly user: PasswdEntry; readonly message: string } | { readonly _tag: "Unavailable"; readonly message: string; readonly suggestion: string }; -const suggestion = `Set ${NATIVE_POSTGRES_USER_ENV}= to run PostgreSQL as a non-root user.`; +const suggestion = + `Set ${NATIVE_POSTGRES_USER_ENV}= to run PostgreSQL as a non-root user, creating one if ` + + "needed (on Linux, for example `useradd --system --user-group supabase-postgres`)."; const unavailable = (message: string): NativePostgresUser => ({ _tag: "Unavailable", message, diff --git a/packages/stack/src/runtime/postgres-user.unit.test.ts b/packages/stack/src/runtime/postgres-user.unit.test.ts index bef7faf9dd..99f0b03e15 100644 --- a/packages/stack/src/runtime/postgres-user.unit.test.ts +++ b/packages/stack/src/runtime/postgres-user.unit.test.ts @@ -18,6 +18,7 @@ const nobody = entry("nobody", 65_534); const ubuntu = entry("ubuntu", 1000); const dev = entry("dev", 1001); const sandbox = { CLAUDECODE: "1", IS_SANDBOX: "yes" }; +const modal = { MODAL_SANDBOX_ID: "sb-01" }; const override = (name: string) => ({ [NATIVE_POSTGRES_USER_ENV]: name }); const asRoot = (env: Record, passwd: ReadonlyArray) => @@ -82,7 +83,7 @@ describe("resolvePostgresUser", () => { expect(asRoot({ CLAUDECODE: "1" }, [root, ubuntu])).toEqual({ _tag: "Unavailable", message: "PostgreSQL cannot be run as root", - suggestion: `Set ${NATIVE_POSTGRES_USER_ENV}= to run PostgreSQL as a non-root user.`, + suggestion: expect.stringContaining(`Set ${NATIVE_POSTGRES_USER_ENV}=`), }); }); @@ -92,4 +93,20 @@ describe("resolvePostgresUser", () => { "Running as root in Claude Code sandbox; PostgreSQL will run as preferred user 'ubuntu' (uid 1000)", }); }); + + it("detects a Modal Sandbox and scans for its node account, since it has no preferred user", () => { + expect(userOf(modal, [root, entry("node", 1000)])).toBe("node"); + }); + + it("suggests creating an account when a Modal Sandbox has none usable", () => { + expect(asRoot(modal, [root])).toEqual({ + _tag: "Unavailable", + message: "PostgreSQL cannot be run as root and Modal Sandbox has no non-root user", + suggestion: expect.stringContaining("useradd --system --user-group supabase-postgres"), + }); + }); + + it("does not detect a sandbox from MODAL_TASK_ID alone", () => { + expect(userOf({ MODAL_TASK_ID: "ta-01" }, [root, entry("node", 1000)])).toBe("Unavailable"); + }); }); diff --git a/packages/stack/tests/compiled-dispatch-fixture.ts b/packages/stack/tests/compiled-dispatch-fixture.ts index 2c34f98a4b..a4ac872efd 100644 --- a/packages/stack/tests/compiled-dispatch-fixture.ts +++ b/packages/stack/tests/compiled-dispatch-fixture.ts @@ -1,5 +1,5 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; -import { Data, Effect, Layer, Option, Stream } from "effect"; +import { Data, Effect, FileSystem, Layer, Option, Stream } from "effect"; import { runHostProcessIfDispatched, runNativeProcessIfDispatched, @@ -8,6 +8,7 @@ import { connectHost, launchHost, shutdownHost, + ownerExitProbe, waitForOwnerExit, type HostEndpoint, } from "../src/HostProcess.ts"; @@ -34,7 +35,10 @@ if (!(await runHostProcessIfDispatched(argv)) && !(await runNativeProcessIfDispa const refusal = yield* shutdownHost(access, false); if (Option.isSome(refusal)) return yield* new FixtureError({ message: refusal.value.message }); - yield* waitForOwnerExit(access.endpoint.pid); + yield* waitForOwnerExit( + access.endpoint.pid, + ownerExitProbe(yield* FileSystem.FileSystem), + ); } output(access.endpoint); }),