From 55d4664897c7605892feb9f4300d7a64a5d57b19 Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 28 Sep 2026 11:16:55 +0200 Subject: [PATCH 1/4] fix(stack): reduce native stack friction in agent sandboxes Running the native stack inside agent sandboxes surfaced several first-run failures: - Resolve native artifact checksums from the S3 mirror's per-target SHA256SUMS so hosts that only reach S3 can prepare artifacts; report every failed checksum/mirror source with its detail. - Treat a Linux zombie owner as exited so `stack stop` succeeds when PID 1 never reaps orphans. - Detect Modal Sandboxes for the root to non-root PostgreSQL step-down and suggest creating an account when none is usable. - Include recent stdout/stderr in native service health-timeout and exit errors. - Forward the owner failure reason on StackError and suggest installing Docker or using `--runtime native` when Docker is unreachable. Co-Authored-By: Claude Opus 5.5 --- apps/cli/docs/stack-commands.md | 2 + .../experimental/stack/start/SIDE_EFFECTS.md | 5 +- .../experimental/stack/start/start.handler.ts | 29 +++- .../stack/start/start.integration.test.ts | 5 + packages/stack/README.md | 2 +- packages/stack/src/Artifacts.ts | 2 + packages/stack/src/HostProcess.ts | 102 +++++++---- packages/stack/src/HostProcess.unit.test.ts | 40 ++++- packages/stack/src/Orchestrator.ts | 17 +- packages/stack/src/Rpc.ts | 2 + ...ost.container-shutdown.integration.test.ts | 14 +- packages/stack/src/effect.ts | 8 +- packages/stack/src/internal/error-message.ts | 14 ++ .../src/preparation/SlimServicesSource.ts | 75 +++++---- .../slim-services.integration.test.ts | 55 +++++- packages/stack/src/runtime/postgres-user.ts | 12 +- .../src/runtime/postgres-user.unit.test.ts | 19 ++- .../ProcessRecipe.integration.test.ts | 158 +++++++++++++++--- packages/stack/src/services/ProcessRecipe.ts | 58 +++++-- 19 files changed, 492 insertions(+), 127 deletions(-) create mode 100644 packages/stack/src/internal/error-message.ts diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 3ddf1f803c..cccc673186 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -224,6 +224,8 @@ start configuration without changing the project file. Valid names are `rest`, ` Excluding `rest` while Studio remains selected is rejected; excluding `analytics` does not affect Studio. The effective configuration is retained in stack state, so starting without `--exclude` restores the project's configured services. +When Docker is unreachable, the failure suggests +`--runtime native` on platforms where native is the default. `supabase stack stop --all` stops every managed stack while preserving data. Discovery fails closed when any registry entry is unreadable, so no partial stop operation is attempted. Individual stop 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 88b2dea447..361b1aba32 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -31,8 +31,11 @@ use `$SUPABASE_HOME/cache/stack`. Storage files use the caller-owned project dir For a new stack, `--runtime auto` selects native on Linux x64/arm64 and macOS arm64, and Docker elsewhere. An existing stack keeps its saved runtime. Explicit runtime selection has no fallback. +When Docker is unreachable, the reported failure suggests `--runtime native` on platforms where +native is the default runtime. + 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 812dcedda4..96c0d8122a 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -93,6 +93,33 @@ const stackError = ( }); }; +const dockerUnavailableSuggestion = (runtimeInfo: { + readonly platform: string; + readonly arch: string; +}) => + defaultStackRuntime(runtimeInfo) === "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 }; + }, +) => { + 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), + cause: base.cause, + }); +}; + const loadStartConfig = (projectRoot: string, fs: FileSystem.FileSystem, path: Path.Path) => Effect.gen(function* () { const config = yield* loadStackConfig(projectRoot); @@ -296,7 +323,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags ), ), ), - ).pipe(Effect.mapError(stackError)); + ).pipe(Effect.mapError((cause) => stackAcquireError(cause, { selectedRuntime, runtime }))); const existingServices = yield* stack.services.list.pipe(Effect.mapError(stackError)); const composition = yield* stack.composition.describe.pipe(Effect.mapError(stackError)); const currentInstances = yield* Effect.forEach(composition.members, ({ id }) => 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 1b414bde07..aca9b37def 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 @@ -830,6 +830,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/packages/stack/README.md b/packages/stack/README.md index 8ab0eb2c0c..eebd110b16 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -73,7 +73,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. ## Composition and operation scope diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 78b6d5032c..2a244bd977 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -204,6 +204,8 @@ const artifactFor = ( repository: `${SLIM_NATIVE_GHCR_REPOSITORY}/${sourceService}`, tag: `${resolved.version}-native-${target}`, }, + // Per-target file on the same S3 mirror, for sandboxes that block both GitHub and ghcr.io. + { kind: "sha256sums", url: `${supabaseS3}/${assetName}.SHA256SUMS` }, ], mirrors: [ { diff --git a/packages/stack/src/HostProcess.ts b/packages/stack/src/HostProcess.ts index 2a7ed88376..c8a88fb58f 100644 --- a/packages/stack/src/HostProcess.ts +++ b/packages/stack/src/HostProcess.ts @@ -1,5 +1,16 @@ import { NodeHttpServer } from "@effect/platform-node"; -import { Data, Effect, Duration, Exit, Option, Schedule, Schema, Scope, Stream } from "effect"; +import { + Data, + Effect, + Duration, + Exit, + FileSystem, + Option, + Schedule, + Schema, + Scope, + Stream, +} from "effect"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -18,14 +29,17 @@ export class HostProcessError extends Data.TaggedError("HostProcessError")<{ readonly cause?: unknown; readonly reason?: HostFailureReason; }> {} -type HostFailureReason = - | "missing-control-listener" - | "connection-failure" - | "bind-conflict" - | "runtime-unavailable" - | "invalid-owner-pid" - | "owner-exit-pending" - | "owner-exit-probe"; +/** Why an owner process could not be launched, reached, or confirmed exited. */ +export const HostFailureReason = Schema.Literals([ + "missing-control-listener", + "connection-failure", + "bind-conflict", + "runtime-unavailable", + "invalid-owner-pid", + "owner-exit-pending", + "owner-exit-probe", +]); +type HostFailureReason = typeof HostFailureReason.Type; const HostIdentity = Schema.Struct({ projectRoot: Schema.String, branchContext: Schema.String, @@ -260,34 +274,58 @@ export type OwnerExitProbeResult = | { 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. A zombie answers it until its parent reaps it, and a sandbox + * PID 1 often never does, so on Linux a `Z` or `X` state in `/proc//stat` counts as exited. + */ +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: "absent" } as const) + : signalResult; + }); /** Waits until the captured owner PID is absent from the process table. */ export const waitForOwnerExit = Effect.fn("HostProcess.waitForOwnerExit")(function* ( pid: number, - probe: OwnerExitProbe = probeOwnerExit, + probe: OwnerExitProbe, ) { if (!Number.isSafeInteger(pid) || pid <= 0) return yield* error( diff --git a/packages/stack/src/HostProcess.unit.test.ts b/packages/stack/src/HostProcess.unit.test.ts index 96a8c78067..2793266437 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,40 @@ 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("treats a zombie or dead owner as exited on Linux", () => + Effect.gen(function* () { + const probe = (stat: string) => ownerExitProbe(statOf(stat), "linux")(process.pid); + expect(yield* probe(`${process.pid} (node) Z 1 1 1 0 -1 4194560`)).toEqual({ state: "absent" }); + expect(yield* probe(`${process.pid} (node) X 1 1 1 0 -1 4194560`)).toEqual({ state: "absent" }); + expect(yield* probe(`${process.pid} (my ) weird) proc) Z 1 1 1 0 -1 4194304`)).toEqual({ + state: "absent", + }); + expect(yield* probe(`${process.pid} (node) S 1 1 1 0 -1 4194560`)).toEqual({ + state: "present", + }); + }), +); + +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 0f00f78e92..3d205c3317 100644 --- a/packages/stack/src/Orchestrator.ts +++ b/packages/stack/src/Orchestrator.ts @@ -23,6 +23,7 @@ import { type ServiceObservation, } from "./Service.ts"; import type { ServiceAdmission } from "./Service.ts"; +import { errorChainMessage } from "./internal/error-message.ts"; export type OrchestratorOperation = | "start" @@ -50,21 +51,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 6dab524166..6ef7408628 100644 --- a/packages/stack/src/Rpc.ts +++ b/packages/stack/src/Rpc.ts @@ -1,5 +1,6 @@ import { Data, Schema } from "effect"; import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { HostFailureReason } from "./HostProcess.ts"; import { ServiceCreation, ServiceCreationInput } from "./services/Catalog.ts"; import { CompositionConfig } from "./Orchestrator.ts"; import { PgProveOptions, PostgresTool } from "./Tools.ts"; @@ -15,6 +16,7 @@ export const StackErrorSchema = Schema.TaggedStruct("StackError", { operation: Schema.String, message: Schema.String, outcomes: Schema.optionalKey(Schema.Array(Outcome)), + reason: Schema.optionalKey(HostFailureReason), }); type StackErrorPayload = Omit, "_tag">; diff --git a/packages/stack/src/StackHost.container-shutdown.integration.test.ts b/packages/stack/src/StackHost.container-shutdown.integration.test.ts index d663403761..a47f941d02 100644 --- a/packages/stack/src/StackHost.container-shutdown.integration.test.ts +++ b/packages/stack/src/StackHost.container-shutdown.integration.test.ts @@ -4,7 +4,7 @@ import { Context, Crypto, Effect, FileSystem, Layer, Path, Redacted, Stream } fr import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as State from "./State.ts"; -import { launchHost, waitForOwnerExit } from "./HostProcess.ts"; +import { launchHost, ownerExitProbe, waitForOwnerExit } from "./HostProcess.ts"; import { StackRpc } from "./Rpc.ts"; import { makeContainerRuntime } from "./runtime/Container.ts"; import { makeDockerDatabaseRoot } from "../tests/docker-fixture.ts"; @@ -125,7 +125,9 @@ it.live.skipIf(process.platform === "win32")( let stoppedB = true; const signalAndWait = (endpoint: { pid: number }, signal: NodeJS.Signals) => Effect.sync(() => process.kill(endpoint.pid, signal)).pipe( - Effect.andThen(waitForOwnerExit(endpoint.pid).pipe(Effect.timeout("15 seconds"))), + Effect.andThen( + waitForOwnerExit(endpoint.pid, ownerExitProbe(fs)).pipe(Effect.timeout("15 seconds")), + ), ); yield* Effect.addFinalizer(() => Effect.gen(function* () { @@ -227,7 +229,9 @@ it.live.skipIf(process.platform === "win32")( yield* clientFor(endpointA2.port).pipe( Effect.flatMap((rpc) => rpc.shutdown({ destroy: 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([]); @@ -239,7 +243,9 @@ it.live.skipIf(process.platform === "win32")( yield* clientFor(endpointB2.port).pipe( Effect.flatMap((rpc) => rpc.shutdown({ destroy: 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 2c35b17b8c..d63666ddac 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -24,6 +24,7 @@ import { controlPortHeld, HostProcessError, launchHost, + ownerExitProbe, waitForOwnerExit, } from "./HostProcess.ts"; import { removeStackContainersCommand } from "./runtime/Container.ts"; @@ -103,6 +104,9 @@ const failure = (operation: string, cause: unknown): StackError => : cause instanceof Error ? cause.message : String(cause), + ...(cause instanceof HostProcessError && cause.reason !== undefined + ? { reason: cause.reason } + : {}), }); type Kind = ServiceCreation["service"]; @@ -334,7 +338,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, ); @@ -353,7 +357,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..8b66650166 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,30 +225,39 @@ const downloadToFile = Effect.fn("SlimServicesSource.downloadToFile")(function* }); }); +/** The failure's cause chain, or its own message when it wraps nothing. */ 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})`; + if (error.cause === undefined) return error.message; + const chain = errorChainMessage(error.cause); + return chain.length > 0 ? chain : error.message; }; /** * 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, 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 [sole] = failures; + if (failures.length === 1 && sole !== undefined) return Effect.fail(sole[1]); + const detail = failures + .map(([failed, error]) => `${describe(failed)} (${fallbackDetail(error)})`) + .join("; "); + return Effect.fail(new PreparationError({ message: `${label}: ${detail}` })); + } return attempt(candidate).pipe( Effect.tap(() => Effect.logInfo(`Slim-services used ${describe(candidate)}`)), Effect.tapError((cause) => @@ -255,10 +265,12 @@ const firstSuccess = ( `Slim-services fallback ${describe(candidate)} failed: ${fallbackDetail(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 +334,28 @@ 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.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 +512,7 @@ export const makeSlimServicesSource = ( return yield* Effect.gen(function* () { const artifact = yield* resolveArtifact(request); yield* firstSuccess( + "Unable to download the slim-services archive", 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..8e7a3b21f4 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,10 @@ 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 (HTTP 403); " + + "registry.test/supabase/cli/demo:v1.0.0-native-linux-amd64 (HTTP 403)", + ); expect(requested).toEqual([ "https://release.test/SHA256SUMS", "https://registry.test/token?scope=repository:supabase/cli/demo:pull&service=registry.test", @@ -445,7 +448,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)), ), @@ -888,4 +894,47 @@ describe("native artifact catalog", () => { }).pipe(Effect.provide(NodeServices.layer)), ), ); + + it.live("falls back to the S3 SHA256SUMS checksum when GitHub and ghcr are both blocked", () => + Effect.scoped( + Effect.gen(function* () { + const { version } = yield* resolveArtifact({ service: "rest" }); + const asset = `postgrest-${version}-linux-amd64`; + const archive = yield* compress(tar("bin/postgrest", "postgrest")); + const crypto = yield* Crypto.Crypto; + const expected = digestHex(yield* crypto.digest("SHA-256", archive)); + const bucket = `https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/postgrest/${version}`; + const served: string[] = []; + const fetcher: FetchLike = (input) => { + const url = requestUrl(input); + if (url.startsWith("https://github.com/") || url.startsWith("https://ghcr.io/")) + return Promise.resolve(new Response("", { status: 403 })); + served.push(url); + if (url === `${bucket}/${asset}.SHA256SUMS`) + return Promise.resolve(new Response(`${expected} ${asset}.tar.zst\n`)); + if (url.endsWith(".manifest.json")) + return Promise.resolve( + new Response( + JSON.stringify({ service: "postgrest", version, target: "linux-amd64" }), + ), + ); + return Promise.resolve(new Response(archive)); + }; + const fs = yield* FileSystem.FileSystem; + const cacheRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "slim-services-s3-checksum-", + }); + const prepared = yield* withFetch( + fetcher, + prepareNativeArtifact({ service: "rest" }, cacheRoot, { os: "linux", arch: "x64" }), + ); + expect(yield* fs.readFileString(prepared.executable)).toBe("postgrest"); + expect(served).toEqual([ + `${bucket}/${asset}.SHA256SUMS`, + `${bucket}/${asset}.manifest.json`, + `${bucket}/${asset}.tar.zst`, + ]); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); }); diff --git a/packages/stack/src/runtime/postgres-user.ts b/packages/stack/src/runtime/postgres-user.ts index 3f41b876bf..f049d14baa 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 (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/src/services/ProcessRecipe.integration.test.ts b/packages/stack/src/services/ProcessRecipe.integration.test.ts index ff36a2fcef..4fdd044064 100644 --- a/packages/stack/src/services/ProcessRecipe.integration.test.ts +++ b/packages/stack/src/services/ProcessRecipe.integration.test.ts @@ -463,7 +463,8 @@ const realtimeService = Effect.fn(function* (container: ContainerRuntime) { const platform = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); -const nativePoolerArtifact = Effect.fn(function* (cacheRoot: string) { +/** The native artifact target for the current test host, or a typed failure on unsupported hosts. */ +const currentNativeTestTarget = Effect.fn(function* () { const platformName = `${process.platform}-${process.arch}`; const target = platformName === "darwin-arm64" @@ -474,13 +475,44 @@ const nativePoolerArtifact = Effect.fn(function* (cacheRoot: string) { ? "linux-arm64" : undefined; if (target === undefined) return yield* Effect.fail(`Unsupported test platform: ${platformName}`); + return target; +}); + +/** Prepares a fake native artifact from an in-memory fixture, sharing the store/source scaffolding every native fixture needs. */ +const prepareNativeFixture = Effect.fn(function* ( + cacheRoot: string, + request: ArtifactRequest, + label: string, + writeFixture: ( + fs: FileSystem.FileSystem, + destination: string, + ) => Effect.Effect, +) { + const fs = yield* FileSystem.FileSystem; + const source: ArtifactSource = { + checksum: () => Effect.succeed("0".repeat(64)), + materialize: (_entry, destination) => + writeFixture(fs, destination).pipe( + Effect.mapError( + (cause) => + new PreparationError({ + message: `Unable to write native ${label} fixture: ${cause.message}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot, source }); + yield* store.prepare(request); +}); +const nativePoolerArtifact = Effect.fn(function* (cacheRoot: string) { + const target = yield* currentNativeTestTarget(); const request: ArtifactRequest = { key: `slim-services/pooler/v2.9.12/${target}`, requiredRuntimePaths: ["bin/server", "bin/prepare", "bin/provision-tenant"], executablePath: "bin/server", }; - const fs = yield* FileSystem.FileSystem; const server = `#!${process.execPath}\nconst http = require("node:http");\n` + `const port = Number(process.env.PORT);\n` + @@ -498,28 +530,32 @@ const nativePoolerArtifact = Effect.fn(function* (cacheRoot: string) { `console.log("Running SupavisorWeb.Endpoint at 127.0.0.1:" + port + " (http)");\n` + `});\n`; const oneShot = `#!${process.execPath}\nprocess.exit(0);\n`; - const source: ArtifactSource = { - checksum: () => Effect.succeed("0".repeat(64)), - materialize: (_entry, destination) => - Effect.gen(function* () { - yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); - yield* fs.writeFileString(`${destination}/bin/server`, server); - yield* fs.writeFileString(`${destination}/bin/prepare`, oneShot); - yield* fs.writeFileString(`${destination}/bin/provision-tenant`, oneShot); - yield* fs.chmod(`${destination}/bin/prepare`, 0o755); - yield* fs.chmod(`${destination}/bin/provision-tenant`, 0o755); - }).pipe( - Effect.mapError( - (cause) => - new PreparationError({ - message: `Unable to write native Pooler fixture: ${cause.message}`, - cause, - }), - ), - ), + yield* prepareNativeFixture(cacheRoot, request, "Pooler", (fs, destination) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/server`, server); + yield* fs.writeFileString(`${destination}/bin/prepare`, oneShot); + yield* fs.writeFileString(`${destination}/bin/provision-tenant`, oneShot); + yield* fs.chmod(`${destination}/bin/prepare`, 0o755); + yield* fs.chmod(`${destination}/bin/provision-tenant`, 0o755); + }), + ); +}); + +const nativeRestArtifact = Effect.fn(function* (cacheRoot: string, script: string) { + const target = yield* currentNativeTestTarget(); + const request: ArtifactRequest = { + key: `slim-services/postgrest/v16.2/${target}`, + requiredRuntimePaths: ["bin/postgrest"], + executablePath: "bin/postgrest", }; - const store = yield* makeArtifactStore({ cacheRoot, source }); - yield* store.prepare(request); + yield* prepareNativeFixture(cacheRoot, request, "rest", (fs, destination) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/postgrest`, script); + yield* fs.chmod(`${destination}/bin/postgrest`, 0o755); + }), + ); }); const NativeLaunchPayload = Schema.Struct({ @@ -1141,4 +1177,80 @@ describe("process recipe startup", () => { }), ).pipe(Effect.provide(platform)), ); + + it.effect("includes the native process's recent output when plain HTTP readiness times out", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const client = yield* HttpClient.HttpClient; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "process-recipe-plain-readiness-", + }); + const cacheRoot = path.join(root, "cache"); + // A second line, printed after the first has had time to flush, lets the test wait for + // the tail Ref to actually observe the first line instead of racing the drain fiber. + const drainMarker = "process-recipe-stderr-drained"; + yield* nativeRestArtifact( + cacheRoot, + `#!${process.execPath}\nconsole.error("connecting to postgres failed: retrying");\n` + + `setTimeout(() => { console.error("${drainMarker}"); }, 10);\n` + + `setInterval(() => {}, 1000);\n`, + ); + const stderrDrained = yield* Deferred.make(); + const decoder = new TextDecoder(); + let stderrSeen = ""; + const observingSpawner: typeof spawner = { + ...spawner, + spawn: (command) => + spawner.spawn(command).pipe( + Effect.map((handle) => ({ + ...handle, + stderr: handle.stderr.pipe( + // The marker can span pipe chunks, so match against everything seen so far. + Stream.tap((bytes) => { + stderrSeen += decoder.decode(bytes, { stream: true }); + return stderrSeen.includes(drainMarker) + ? Deferred.succeed(stderrDrained, undefined) + : Effect.void; + }), + ), + })), + ), + }; + // A client that never resolves leaves the readiness timeout as the only pending timer. + const hangingClient: HttpClient.HttpClient = { ...client, execute: () => Effect.never }; + const nativeOptions: CatalogOptions = { ...options, root, cacheRoot, runtime: "native" }; + const nativeSpec: ProcessRecipeSpec = { ...spec, startup: [] }; + const recipe = yield* makeProcessRecipe( + creation, + nativeOptions, + { + fs, + path, + crypto, + client: hangingClient, + spawner: observingSpawner, + container: undefined, + }, + nativeSpec, + ); + if (recipe.definition.prepare !== undefined) yield* recipe.definition.prepare(creation); + const scope = yield* Scope.fork(yield* Effect.scope, "sequential"); + const runtime = yield* recipe.definition.launch({ id: "rest", config: creation, scope }); + + const failure = yield* Effect.flip(runtime.health).pipe(Effect.forkChild); + yield* Deferred.await(stderrDrained); + yield* TestClock.adjust("61 seconds"); + const error = yield* Fiber.join(failure); + + expect(error.message).toContain("Service health timed out"); + expect(error.message).toContain("Recent stderr:"); + expect(error.message).toContain("connecting to postgres failed: retrying"); + yield* runtime.stop; + }), + ).pipe(Effect.provide(platform)), + ); }); diff --git a/packages/stack/src/services/ProcessRecipe.ts b/packages/stack/src/services/ProcessRecipe.ts index 2602ca60c3..82064d273f 100644 --- a/packages/stack/src/services/ProcessRecipe.ts +++ b/packages/stack/src/services/ProcessRecipe.ts @@ -119,17 +119,29 @@ const catalogError = (operation: string, message: string, service?: ServiceKind, ...(cause === undefined ? {} : { cause }), }); +interface OutputTail { + readonly stdout: Ref.Ref>; + readonly stderr: Ref.Ref>; + readonly drained: Deferred.Deferred; +} + const processExit = ( exitCode: Effect.Effect, + output?: OutputTail, ): Effect.Effect> => exitCode.pipe( - Effect.flatMap((code) => - Number(code) === 0 - ? Effect.void - : Effect.fail( - new ServiceError({ operation: "exit", message: `Process exited with ${code}` }), - ), - ), + Effect.flatMap((code) => { + if (Number(code) === 0) return Effect.void; + const error = new ServiceError({ operation: "exit", message: `Process exited with ${code}` }); + if (output === undefined) return Effect.fail(error); + // Drain fibers may still be flushing the final chunk when the exit code resolves. + return Deferred.await(output.drained).pipe( + Effect.timeout("2 seconds"), + Effect.ignore, + Effect.andThen(attachOutputTail(error, output)), + Effect.flatMap(Effect.fail), + ); + }), Effect.mapError((cause) => serviceError("exit", cause)), Effect.exit, ); @@ -234,6 +246,18 @@ const withRecentOutput = (summary: string, output: StartupOutput) => .map((name) => `Recent ${name}:\n${output[name].join("\n")}`), ].join("\n"); +const attachOutputTail = (error: ServiceError, output: OutputTail): Effect.Effect => + Effect.all([Ref.get(output.stdout), Ref.get(output.stderr)]).pipe( + Effect.map( + ([stdout, stderr]) => + new ServiceError({ + operation: error.operation, + message: withRecentOutput(error.message, { stdout, stderr }), + cause: error.cause, + }), + ), + ); + const awaitStartup = Effect.fn("ProcessRecipe.awaitStartup")( ( service: ServiceKind, @@ -607,14 +631,24 @@ export const makeProcessRecipe = ); if (spec.nativeReadinessOutput === undefined) { yield* Ref.set(endpoints, selected); - yield* publishLogs(native, logs, attemptScope); + const plainOutput = yield* collectNativeOutput( + native, + logs, + selected, + undefined, + attemptScope, + ); const ready = selected.get("http"); return { health: ready === undefined ? Effect.fail(serviceError("health", "Recipe has no HTTP readiness endpoint")) - : readiness(deps.client, ready, spec.healthPath), - exit: processExit(native.exitCode), + : readiness(deps.client, ready, spec.healthPath).pipe( + Effect.catch((error) => + attachOutputTail(error, plainOutput).pipe(Effect.flatMap(Effect.fail)), + ), + ), + exit: processExit(native.exitCode, plainOutput), stop: native.kill.pipe(Effect.mapError((cause) => serviceError("stop", cause))), remove: Ref.set(endpoints, new Map()), } satisfies RuntimeSession; @@ -656,7 +690,7 @@ export const makeProcessRecipe = yield* Ref.set(endpoints, selected); return { health: Effect.void, - exit: processExit(native.exitCode), + exit: processExit(native.exitCode, output), stop: native.kill.pipe(Effect.mapError((cause) => serviceError("stop", cause))), remove: Ref.set(endpoints, new Map()), } satisfies RuntimeSession; @@ -680,7 +714,7 @@ export const makeProcessRecipe = yield* Ref.set(endpoints, selected); return { health: Effect.fail(failure), - exit: processExit(native.exitCode), + exit: processExit(native.exitCode, output), stop: native.kill.pipe(Effect.mapError((cause) => serviceError("stop", cause))), remove: Ref.set(endpoints, new Map()), } satisfies RuntimeSession; From 2a5823be99cfb4c2bb97a28811266f03e04b60b9 Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 28 Sep 2026 11:31:39 +0200 Subject: [PATCH 2/4] fix(stack): address review and CI findings on sandbox friction fixes - Keep the S3 bucket out of checksum authority, per ADR 0026. - Treat a zombie owner as pending until the exit wait ends, so a reaping parent can clear it and only a never-reaped owner is accepted as exited. - Keep each failure's outer message and the artifact context in the aggregated fallback error, and document the aggregation in ADR 0026. - Suggest --runtime native only when creating a new stack. - Follow develop's startupCommands rename in the output-tail test. Co-Authored-By: Claude Opus 5.5 --- .../experimental/stack/start/start.handler.ts | 20 ++++--- docs/adr/0026-slim-artifact-mirrors.md | 2 +- packages/stack/src/Artifacts.ts | 2 - packages/stack/src/HostProcess.ts | 52 +++++++++++++------ packages/stack/src/HostProcess.unit.test.ts | 42 ++++++++++++--- .../src/preparation/SlimServicesSource.ts | 24 ++++----- .../slim-services.integration.test.ts | 49 ++--------------- .../ProcessRecipe.integration.test.ts | 2 +- 8 files changed, 101 insertions(+), 92 deletions(-) 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 96c0d8122a..9669f629e3 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -93,11 +93,12 @@ const stackError = ( }); }; -const dockerUnavailableSuggestion = (runtimeInfo: { - readonly platform: string; - readonly arch: string; -}) => - defaultStackRuntime(runtimeInfo) === "native" +// 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 && defaultStackRuntime(runtimeInfo) === "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."; @@ -106,6 +107,7 @@ const stackAcquireError = ( runtimeContext: { readonly selectedRuntime: "native" | "docker" | "podman"; readonly runtime: { readonly platform: string; readonly arch: string }; + readonly creating: boolean; }, ) => { const base = stackError(cause); @@ -115,7 +117,7 @@ const stackAcquireError = ( reason: "runtime", message: base.message, ...(base.detail === undefined ? {} : { detail: base.detail }), - suggestion: dockerUnavailableSuggestion(runtimeContext.runtime), + suggestion: dockerUnavailableSuggestion(runtimeContext.runtime, runtimeContext.creating), cause: base.cause, }); }; @@ -323,7 +325,11 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags ), ), ), - ).pipe(Effect.mapError((cause) => stackAcquireError(cause, { selectedRuntime, runtime }))); + ).pipe( + Effect.mapError((cause) => + stackAcquireError(cause, { selectedRuntime, runtime, creating: target.id === undefined }), + ), + ); const existingServices = yield* stack.services.list.pipe(Effect.mapError(stackError)); const composition = yield* stack.composition.describe.pipe(Effect.mapError(stackError)); const currentInstances = yield* Effect.forEach(composition.members, ({ id }) => 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/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 69002e0187..d2f8c1ed1c 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -204,8 +204,6 @@ const artifactFor = ( repository: `${SLIM_NATIVE_GHCR_REPOSITORY}/${sourceService}`, tag: `${resolved.version}-native-${target}`, }, - // Per-target file on the same S3 mirror, for sandboxes that block both GitHub and ghcr.io. - { kind: "sha256sums", url: `${supabaseS3}/${assetName}.SHA256SUMS` }, ], mirrors: [ { diff --git a/packages/stack/src/HostProcess.ts b/packages/stack/src/HostProcess.ts index c8a88fb58f..af24377e3a 100644 --- a/packages/stack/src/HostProcess.ts +++ b/packages/stack/src/HostProcess.ts @@ -37,6 +37,7 @@ export const HostFailureReason = Schema.Literals([ "runtime-unavailable", "invalid-owner-pid", "owner-exit-pending", + "owner-exit-zombie", "owner-exit-probe", ]); type HostFailureReason = typeof HostFailureReason.Type; @@ -271,6 +272,7 @@ const isBindConflict = (failure: HostProcessError) => { 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; @@ -283,10 +285,7 @@ const procStatState = (stat: string): string | undefined => .trim() .split(/\s+/u)[0]; -/** - * Probes the owner PID with signal 0. A zombie answers it until its parent reaps it, and a sandbox - * PID 1 often never does, so on Linux a `Z` or `X` state in `/proc//stat` counts as exited. - */ +/** 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, @@ -318,11 +317,14 @@ export const ownerExitProbe = ( Effect.orElseSucceed(() => undefined), ); return state !== undefined && zombieProcStates.has(state) - ? ({ state: "absent" } as const) + ? ({ 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, @@ -334,25 +336,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 2793266437..6ac07fd48e 100644 --- a/packages/stack/src/HostProcess.unit.test.ts +++ b/packages/stack/src/HostProcess.unit.test.ts @@ -125,20 +125,46 @@ it.effect("rejects an invalid owner PID before probing", () => const statOf = (stat: string) => FileSystem.makeNoop({ readFileString: () => Effect.succeed(stat) }); -it.effect("treats a zombie or dead owner as exited on Linux", () => +it.effect("reports a zombie or dead owner on Linux", () => Effect.gen(function* () { const probe = (stat: string) => ownerExitProbe(statOf(stat), "linux")(process.pid); - expect(yield* probe(`${process.pid} (node) Z 1 1 1 0 -1 4194560`)).toEqual({ state: "absent" }); - expect(yield* probe(`${process.pid} (node) X 1 1 1 0 -1 4194560`)).toEqual({ state: "absent" }); - expect(yield* probe(`${process.pid} (my ) weird) proc) Z 1 1 1 0 -1 4194304`)).toEqual({ - state: "absent", - }); - expect(yield* probe(`${process.pid} (node) S 1 1 1 0 -1 4194560`)).toEqual({ - state: "present", + 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({ diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts index 8b66650166..8fec869ce3 100644 --- a/packages/stack/src/preparation/SlimServicesSource.ts +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -225,13 +225,6 @@ const downloadToFile = Effect.fn("SlimServicesSource.downloadToFile")(function* }); }); -/** The failure's cause chain, or its own message when it wraps nothing. */ -const fallbackDetail = (error: PreparationError): string => { - if (error.cause === undefined) return error.message; - const chain = errorChainMessage(error.cause); - return chain.length > 0 ? chain : error.message; -}; - /** * Runs `attempt` against each candidate until one succeeds. When every candidate fails, the * returned error names each attempted source with its failure detail (the sole source's own @@ -251,18 +244,25 @@ const firstSuccess = ( ): Effect.Effect => { const [candidate, ...rest] = remaining; if (candidate === undefined) { - const [sole] = failures; - if (failures.length === 1 && sole !== undefined) return Effect.fail(sole[1]); + const [first] = failures; + if (failures.length === 1 && first !== undefined) return Effect.fail(first[1]); const detail = failures - .map(([failed, error]) => `${describe(failed)} (${fallbackDetail(error)})`) + .map(([failed, error]) => `${describe(failed)} (${errorChainMessage(error)})`) .join("; "); - return Effect.fail(new PreparationError({ message: `${label}: ${detail}` })); + // The message already carries every failure, so a cause would repeat it in chain renderers. + return Effect.fail( + new PreparationError({ + message: `${label}: ${detail}`, + ...(first?.[1].service === undefined ? {} : { service: first[1].service }), + ...(first?.[1].version === undefined ? {} : { version: first[1].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((cause) => fallback([...failures, [candidate, cause] as const], rest)), diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts index 8e7a3b21f4..f4db650dfb 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -392,8 +392,10 @@ describe("slim-services artifact source", () => { return Promise.resolve(new Response("", { status: 403 })); }, slimServicesChecksum(mirrored, immediate).pipe(Effect.exit)); expect(errorOf(failed)?.message).toBe( - "Unable to resolve the slim-services checksum: https://release.test/SHA256SUMS (HTTP 403); " + - "registry.test/supabase/cli/demo:v1.0.0-native-linux-amd64 (HTTP 403)", + "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(requested).toEqual([ "https://release.test/SHA256SUMS", @@ -894,47 +896,4 @@ describe("native artifact catalog", () => { }).pipe(Effect.provide(NodeServices.layer)), ), ); - - it.live("falls back to the S3 SHA256SUMS checksum when GitHub and ghcr are both blocked", () => - Effect.scoped( - Effect.gen(function* () { - const { version } = yield* resolveArtifact({ service: "rest" }); - const asset = `postgrest-${version}-linux-amd64`; - const archive = yield* compress(tar("bin/postgrest", "postgrest")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const bucket = `https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/postgrest/${version}`; - const served: string[] = []; - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.startsWith("https://github.com/") || url.startsWith("https://ghcr.io/")) - return Promise.resolve(new Response("", { status: 403 })); - served.push(url); - if (url === `${bucket}/${asset}.SHA256SUMS`) - return Promise.resolve(new Response(`${expected} ${asset}.tar.zst\n`)); - if (url.endsWith(".manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "postgrest", version, target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; - const fs = yield* FileSystem.FileSystem; - const cacheRoot = yield* fs.makeTempDirectoryScoped({ - prefix: "slim-services-s3-checksum-", - }); - const prepared = yield* withFetch( - fetcher, - prepareNativeArtifact({ service: "rest" }, cacheRoot, { os: "linux", arch: "x64" }), - ); - expect(yield* fs.readFileString(prepared.executable)).toBe("postgrest"); - expect(served).toEqual([ - `${bucket}/${asset}.SHA256SUMS`, - `${bucket}/${asset}.manifest.json`, - `${bucket}/${asset}.tar.zst`, - ]); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); }); diff --git a/packages/stack/src/services/ProcessRecipe.integration.test.ts b/packages/stack/src/services/ProcessRecipe.integration.test.ts index e9ae1cfc9b..50c296a6d0 100644 --- a/packages/stack/src/services/ProcessRecipe.integration.test.ts +++ b/packages/stack/src/services/ProcessRecipe.integration.test.ts @@ -1224,7 +1224,7 @@ describe("process recipe startup", () => { // A client that never resolves leaves the readiness timeout as the only pending timer. const hangingClient: HttpClient.HttpClient = { ...client, execute: () => Effect.never }; const nativeOptions: CatalogOptions = { ...options, root, cacheRoot, runtime: "native" }; - const nativeSpec: ProcessRecipeSpec = { ...spec, startup: [] }; + const nativeSpec: ProcessRecipeSpec = { ...spec, startupCommands: [] }; const recipe = yield* makeProcessRecipe( creation, nativeOptions, From 733f7faa9b51abb4652b08375064494f72d5a9aa Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 28 Sep 2026 12:57:27 +0200 Subject: [PATCH 3/4] docs(stack): note the native runtime hint applies to new stacks Co-Authored-By: Claude Opus 5.5 --- apps/cli/docs/stack-commands.md | 2 +- .../cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 2c132a32ed..19534fd974 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -230,7 +230,7 @@ Excluding `rest` while Studio remains selected is rejected; excluding `analytics Studio. The effective configuration is retained in stack state, so starting without `--exclude` restores the project's configured services. When Docker is unreachable, the failure suggests -`--runtime native` on platforms where native is the default. +`--runtime native` for a new stack on platforms where native is the default. `supabase stack stop --all` stops every managed stack while preserving data. Registry entries that cannot be read or decoded are skipped with a warning on stderr; only a failure to read the stacks 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 75427cb603..97fb0d5e6f 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -35,8 +35,8 @@ use `$SUPABASE_HOME/cache/stack`. Storage files use the caller-owned project dir For a new stack, `--runtime auto` selects native on Linux x64/arm64 and macOS arm64, and Docker elsewhere. An existing stack keeps its saved runtime. Explicit runtime selection has no fallback. -When Docker is unreachable, the reported failure suggests `--runtime native` on platforms where -native is the default runtime. +When Docker is unreachable, the reported failure suggests `--runtime native` for a new stack on +platforms where native is the default runtime. Native startup refuses root because PostgreSQL `initdb` cannot run as root, unless a Claude Code or Modal Sandbox is detected or `SUPABASE_NATIVE_POSTGRES_USER` names a non-root user. PostgreSQL then runs From 70c28d7425d475420d2af5771388bd93b02d3902 Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 29 Sep 2026 09:53:30 +0200 Subject: [PATCH 4/4] fix(stack): keep artifact identity on aggregated failures and tighten hints - Aggregated checksum and archive failures carry the artifact's service and version, which download errors never supplied. - The root-refusal useradd example is labeled Linux-only. - The Docker-unreachable hint is documented for explicit or saved Docker stacks, next to runtime selection, since auto selection never picks an unreachable Docker. Co-Authored-By: Claude Opus 5.5 --- apps/cli/docs/stack-commands.md | 6 +++--- .../src/commands/experimental/stack/start/SIDE_EFFECTS.md | 4 ++-- packages/stack/src/preparation/SlimServicesSource.ts | 7 +++++-- .../src/preparation/slim-services.integration.test.ts | 4 ++++ packages/stack/src/runtime/postgres-user.ts | 2 +- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index e4671c84cb..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 @@ -237,8 +239,6 @@ start configuration without changing the project file. Valid names are `rest`, ` Excluding `rest` while Studio remains selected is rejected; excluding `analytics` does not affect Studio. The effective configuration is retained in stack state, so starting without `--exclude` restores the project's configured services. -When Docker is unreachable, the failure suggests -`--runtime native` for a new stack on platforms that support native. `supabase stack stop --all` stops every managed stack while preserving data. Registry entries that cannot be read or decoded are skipped with a warning on stderr; only a failure to read the stacks 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 6921e2c8d9..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,8 @@ 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 a requested Docker runtime is unreachable, the reported failure suggests starting Docker, and -`--runtime native` for a new stack on platforms that support native. +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 or Modal Sandbox is detected or `SUPABASE_NATIVE_POSTGRES_USER` names a non-root user. PostgreSQL then runs diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts index 8fec869ce3..f4453347b6 100644 --- a/packages/stack/src/preparation/SlimServicesSource.ts +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -233,6 +233,7 @@ const downloadToFile = Effect.fn("SlimServicesSource.downloadToFile")(function* */ const firstSuccess = ( label: string, + artifact: Pick, candidates: readonly [T, ...ReadonlyArray], describe: (candidate: T) => string, attempt: (candidate: T) => Effect.Effect, @@ -253,8 +254,8 @@ const firstSuccess = ( return Effect.fail( new PreparationError({ message: `${label}: ${detail}`, - ...(first?.[1].service === undefined ? {} : { service: first[1].service }), - ...(first?.[1].version === undefined ? {} : { version: first[1].version }), + service: artifact.service, + version: artifact.version, }), ); } @@ -336,6 +337,7 @@ export const slimServicesChecksum = Effect.fn("SlimServicesSource.checksum")(fun ) { return yield* firstSuccess( "Unable to resolve the slim-services checksum", + artifact, artifact.checksums, describeChecksumSource, (source) => @@ -513,6 +515,7 @@ export const makeSlimServicesSource = ( 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 f4db650dfb..9423210184 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -397,6 +397,10 @@ describe("slim-services artifact source", () => { "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", diff --git a/packages/stack/src/runtime/postgres-user.ts b/packages/stack/src/runtime/postgres-user.ts index b49591f308..975a8e1c87 100644 --- a/packages/stack/src/runtime/postgres-user.ts +++ b/packages/stack/src/runtime/postgres-user.ts @@ -37,7 +37,7 @@ export type NativePostgresUser = const suggestion = `Set ${NATIVE_POSTGRES_USER_ENV}= to run PostgreSQL as a non-root user, creating one if ` + - "needed (for example `useradd --system --user-group supabase-postgres`)."; + "needed (on Linux, for example `useradd --system --user-group supabase-postgres`)."; const unavailable = (message: string): NativePostgresUser => ({ _tag: "Unavailable", message,