diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts index 2a4e98012f..709eacafa9 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts @@ -53,7 +53,7 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio yield* output.info("No local stack is running. Starting the local Supabase stack..."); yield* ensureProjectStateIgnored(cliProjectHome.projectRoot); - const serviceVersionContext = yield* resolveServiceVersionContext([], undefined); + const serviceVersionContext = yield* resolveServiceVersionContext([], undefined, "docker"); const loadedCliConfig = yield* loadCliConfig(cliProjectHome.projectRoot); const stackConfig = { ...withServiceVersions(toStartStackConfig([], "docker"), serviceVersionContext.runtimeVersions), diff --git a/apps/cli/src/next/commands/link/link.handler.ts b/apps/cli/src/next/commands/link/link.handler.ts index 7b3adc86b9..450cc73b9c 100644 --- a/apps/cli/src/next/commands/link/link.handler.ts +++ b/apps/cli/src/next/commands/link/link.handler.ts @@ -129,7 +129,7 @@ export const link = Effect.fnUntraced(function* (flags: LinkFlags) { projectDir: cliProjectHome.projectRoot, })).map((stack) => ({ stackName: stack.name, - services: fillServiceVersionManifest(stack.versions), + services: fillServiceVersionManifest(stack.versions, stack.launch.mode), })), ); const linkedProject = refreshed.linkedProject; diff --git a/apps/cli/src/next/commands/services/services.handler.ts b/apps/cli/src/next/commands/services/services.handler.ts index 95b296a55c..18d2b2a0e8 100644 --- a/apps/cli/src/next/commands/services/services.handler.ts +++ b/apps/cli/src/next/commands/services/services.handler.ts @@ -1,8 +1,4 @@ -import { - fillServiceVersionManifest, - planStackVersions, - resolveStackSummary, -} from "@supabase/stack/effect"; +import { planStackVersions, resolveStackSummary } from "@supabase/stack/effect"; import { Effect, Exit, Option } from "effect"; import { Credentials } from "../../auth/credentials.service.ts"; import { CliSettings } from "../../config/cli-settings.service.ts"; @@ -47,7 +43,10 @@ export const services = Effect.fnUntraced(function* () { const serviceVersionContext = planStackVersions({ ...(Option.isSome(linkedState) ? { candidateBaseline: linkedState.value.versions } : {}), ...(Option.isSome(existingSummary) - ? { pinnedBaseline: fillServiceVersionManifest(existingSummary.value.versions) } + ? { + pinnedBaseline: existingSummary.value.versions, + runtime: existingSummary.value.launch.mode, + } : {}), ...(Option.isSome(localServiceVersions) ? { localOverrides: localServiceVersions.value.versions } diff --git a/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts b/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts index dd8d22e944..2388e14df7 100644 --- a/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts +++ b/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts @@ -1,4 +1,4 @@ -import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; +import { DEFAULT_VERSIONS, DOCKER_DEFAULT_VERSIONS } from "@supabase/stack/effect"; import { describe, expect, test } from "vitest"; import { Effect, Layer } from "effect"; import { @@ -80,4 +80,29 @@ describe("service version overrides", () => { updateFingerprint: undefined, }); }); + + test("uses Docker defaults and tags when explicitly resolving Docker versions", async () => { + await expect( + Effect.runPromise( + resolveServiceVersionContext(["pooler=v2.9.7"], undefined, "docker").pipe( + Effect.provide( + Layer.mergeAll(mockProjectLinkState(), mockCliProjectLocalServiceVersions()), + ), + ), + ), + ).resolves.toMatchObject({ + candidateBaseline: DOCKER_DEFAULT_VERSIONS, + pinnedBaseline: DOCKER_DEFAULT_VERSIONS, + runtimeVersions: { ...DOCKER_DEFAULT_VERSIONS, pooler: "2.9.7" }, + activeOverrides: [{ service: "pooler", version: "2.9.7", source: "flag" }], + }); + }); + + test("suggests the Docker default for an empty Docker override", async () => { + await expect( + Effect.runPromise(parseServiceVersionOverrides(["pooler="], "docker").pipe(Effect.flip)), + ).resolves.toMatchObject({ + suggestion: `Pass --service-version pooler=${DOCKER_DEFAULT_VERSIONS.pooler}.`, + }); + }); }); diff --git a/apps/cli/src/next/commands/start/start.command.ts b/apps/cli/src/next/commands/start/start.command.ts index aa3a33a908..399b1d5c00 100644 --- a/apps/cli/src/next/commands/start/start.command.ts +++ b/apps/cli/src/next/commands/start/start.command.ts @@ -4,8 +4,8 @@ import { DEFAULT_MANAGED_STACK_NAME, daemonLayer, restartManagedStackForUpgrade, - fillServiceVersionManifest, resolveStackSummary, + selectStackRuntime, type StackSummary, } from "@supabase/stack/effect"; import { Command, Flag } from "effect/unstable/cli"; @@ -178,17 +178,17 @@ export const startCommand = Command.make("start", flags).pipe( cwd: runtimeInfo.cwd, name: flags.stack, }).pipe(Effect.catchTag("NoRunningStackError", () => Effect.succeed(undefined))); + const requestedMode = flags.mode ?? existingSummary?.launch.mode; + const effectiveMode = requestedMode ?? (yield* selectStackRuntime()).mode; const serviceVersionContext = yield* resolveServiceVersionContext( flags.serviceVersion, - existingSummary === undefined - ? undefined - : fillServiceVersionManifest(existingSummary.versions), + existingSummary?.versions, + effectiveMode, ); const loadedCliConfig = yield* loadCliConfig(cliProjectHome.projectRoot); // Tri-state in config.toml: unset and explicit `true` both auto-expose new entities in // `public` (the cloud default); only explicit `false` revokes the default Data API GRANTs. const autoExposeNewTables = loadedCliConfig?.config.api.auto_expose_new_tables ?? true; - const effectiveMode = flags.mode ?? existingSummary?.launch.mode; const baseStackConfig = withServiceVersions( toStartStackConfig(flags.exclude, effectiveMode), serviceVersionContext.runtimeVersions, @@ -212,7 +212,7 @@ export const startCommand = Command.make("start", flags).pipe( portDocument: portIntents, }); const launch = { - ...(flags.mode === undefined ? {} : { mode: flags.mode }), + mode: effectiveMode, versions: serviceVersionContext.pinnedBaseline, excludedServices: flags.exclude, ...(existingSummary?.lastNotifiedUpdateFingerprint === undefined diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts index 5cd7ed4f6a..2f80c0def2 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { - DEFAULT_VERSIONS, + DOCKER_DEFAULT_VERSIONS, connectLayer, fillServiceVersionManifest, planStackVersions, @@ -34,8 +34,9 @@ describe("start handler", () => { Effect.provide(fixture.baseLayer), Effect.flatMap((stackLayer) => { const out = mockOutput({ interactive: false }); - const versions = fillServiceVersionManifest(DEFAULT_VERSIONS); + const versions = fillServiceVersionManifest(DOCKER_DEFAULT_VERSIONS, "docker"); const serviceVersionContext = planStackVersions({ + runtime: "docker", pinnedBaseline: versions, candidateBaseline: versions, }); diff --git a/apps/cli/src/next/commands/status/status.handler.ts b/apps/cli/src/next/commands/status/status.handler.ts index a50d902564..1def7974ba 100644 --- a/apps/cli/src/next/commands/status/status.handler.ts +++ b/apps/cli/src/next/commands/status/status.handler.ts @@ -2,7 +2,6 @@ import { Context, Effect, Layer, Option, Predicate } from "effect"; import { loadCliConfig } from "@supabase/config/effect"; import { connectLayer, - fillServiceVersionManifest, resolveStackSummary, Stack, type StackSummary, @@ -183,7 +182,8 @@ export const status = Effect.fnUntraced(function* (_flags: StatusFlags) { const message = "Local Supabase stack is stopped."; const serviceVersionContext = yield* resolveServiceVersionContext( [], - fillServiceVersionManifest(summary.value.versions), + summary.value.versions, + summary.value.launch.mode, ); const data = { stack: summary.value.name, @@ -241,7 +241,8 @@ export const status = Effect.fnUntraced(function* (_flags: StatusFlags) { const { info, services } = stackResult; const serviceVersionContext = yield* resolveServiceVersionContext( [], - fillServiceVersionManifest(summary.versions), + summary.versions, + summary.launch.mode, ); const sortedServices = [...services].sort((a, b) => a.name.localeCompare(b.name)); const allReady = services.every((service) => diff --git a/apps/cli/src/next/commands/update/update.handler.ts b/apps/cli/src/next/commands/update/update.handler.ts index 4f8cff475e..f4a8062ed3 100644 --- a/apps/cli/src/next/commands/update/update.handler.ts +++ b/apps/cli/src/next/commands/update/update.handler.ts @@ -59,7 +59,7 @@ export const update = Effect.fnUntraced(function* (flags: UpdateFlags) { projectDir: cliProjectHome.projectRoot, })).map((stack) => ({ stackName: stack.name, - services: fillServiceVersionManifest(stack.versions), + services: fillServiceVersionManifest(stack.versions, stack.launch.mode), })), ); const changedVersions = diffCachedLinkedVersions( @@ -99,7 +99,11 @@ export const update = Effect.fnUntraced(function* (flags: UpdateFlags) { [], Option.match(existingSummary, { onNone: () => undefined, - onSome: (summary) => fillServiceVersionManifest(summary.versions), + onSome: (summary) => summary.versions, + }), + Option.match(existingSummary, { + onNone: () => undefined, + onSome: (summary) => summary.launch.mode, }), ); diff --git a/apps/cli/src/next/commands/update/update.integration.test.ts b/apps/cli/src/next/commands/update/update.integration.test.ts index 75a0ff4530..652a150cfd 100644 --- a/apps/cli/src/next/commands/update/update.integration.test.ts +++ b/apps/cli/src/next/commands/update/update.integration.test.ts @@ -3,7 +3,7 @@ import { BunServices } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; import { rmSync } from "node:fs"; import { join } from "node:path"; -import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; +import { DOCKER_DEFAULT_VERSIONS } from "@supabase/stack/effect"; import { update } from "./update.handler.ts"; import { mockOutput, @@ -34,7 +34,7 @@ describe("update handler", () => { Effect.tap( Effect.promise(async () => { const document = await fixture.readDocument(); - expect(document?.launch?.versions).toEqual(DEFAULT_VERSIONS); + expect(document?.launch?.versions).toEqual(DOCKER_DEFAULT_VERSIONS); }), ), Effect.ensuring(Effect.promise(() => fixture.dispose())), diff --git a/apps/cli/src/next/config/managed-port-intents.ts b/apps/cli/src/next/config/managed-port-intents.ts index 15ea3805c3..53d9ae7328 100644 --- a/apps/cli/src/next/config/managed-port-intents.ts +++ b/apps/cli/src/next/config/managed-port-intents.ts @@ -1,6 +1,38 @@ import type { LoadedCliConfig } from "@supabase/config"; import { PORT_CATALOG, PORT_FIELDS, portFieldsForConfigInput } from "@supabase/stack/effect"; +const hasOwn = (value: object, key: string): boolean => + Object.prototype.hasOwnProperty.call(value, key); + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null; + +/** + * The CLI's public config still calls the selected pooler listener `port`. + * The stack catalog splits that listener by pool mode, so project documents + * need a derived key before the managed port planner resolves its intents. + */ +const withPoolerPortIntent = (document: Readonly>) => { + const db = document.db; + if (!isRecord(db)) return document; + const pooler = db.pooler; + if (!isRecord(pooler) || !hasOwn(pooler, "port") || typeof pooler.port !== "number") { + return document; + } + + const key = pooler.pool_mode === "session" ? "session_port" : "transaction_port"; + return { + ...document, + db: { + ...db, + pooler: { + ...pooler, + [key]: pooler.port, + }, + }, + }; +}; + /** * Preserve the raw project document alongside the resolved stack config so * explicit sticky ports remain distinct from omitted automatic ports. @@ -13,9 +45,10 @@ export const managedPortIntents = ( const disabledFields = PORT_FIELDS.filter( (field) => PORT_CATALOG[field].persistence === "sticky" && !activeFields.includes(field), ); + const document = withPoolerPortIntent(loadedCliConfig?.document ?? {}); return { activeFields, disabledFields, - document: loadedCliConfig?.document ?? {}, + document, }; }; diff --git a/apps/cli/src/next/config/managed-port-intents.unit.test.ts b/apps/cli/src/next/config/managed-port-intents.unit.test.ts new file mode 100644 index 0000000000..16b80d90cf --- /dev/null +++ b/apps/cli/src/next/config/managed-port-intents.unit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { managedPortIntents } from "./managed-port-intents.ts"; + +const stackConfig = { + mode: "docker", + pooler: {}, +} satisfies Parameters[0]; + +describe("managedPortIntents", () => { + it("maps the configured pooler port to the transaction listener by default", () => { + expect( + managedPortIntents(stackConfig, { + document: { db: { pooler: { port: 6543 } } }, + }).document, + ).toEqual({ db: { pooler: { port: 6543, transaction_port: 6543 } } }); + }); + + it("maps the configured pooler port to the session listener when requested", () => { + expect( + managedPortIntents(stackConfig, { + document: { db: { pooler: { port: 6544, pool_mode: "session" } } }, + }).document, + ).toEqual({ + db: { pooler: { port: 6544, pool_mode: "session", session_port: 6544 } }, + }); + }); + + it("leaves the document unchanged when the pooler port is absent", () => { + const document = { db: { pooler: { pool_mode: "session" } } }; + expect(managedPortIntents(stackConfig, { document }).document).toBe(document); + }); +}); diff --git a/apps/cli/src/next/config/service-version-resolution.ts b/apps/cli/src/next/config/service-version-resolution.ts index ea9c8c4ce1..53d5122ddc 100644 --- a/apps/cli/src/next/config/service-version-resolution.ts +++ b/apps/cli/src/next/config/service-version-resolution.ts @@ -1,6 +1,6 @@ -import type { ServiceName, StackVersionPlan, VersionManifest } from "@supabase/stack/effect"; +import type { ServiceName, StackVersionPlan, VersionRuntime } from "@supabase/stack/effect"; import { - DEFAULT_VERSIONS, + defaultVersionsForRuntime, normalizeServiceVersion, planStackVersions, SERVICE_NAMES, @@ -33,6 +33,7 @@ function isServiceName(value: string): value is ServiceName { export const parseServiceVersionOverrides = Effect.fnUntraced(function* ( rawOverrides: ReadonlyArray, + runtime: VersionRuntime = "native", ) { const overrides: Partial> = {}; @@ -55,12 +56,12 @@ export const parseServiceVersionOverrides = Effect.fnUntraced(function* ( return yield* Effect.fail( new InvalidServiceVersionOverrideError({ detail: `Invalid service version override '${rawOverride}'. Expected format service=version.`, - suggestion: `Pass --service-version ${rawService}=${DEFAULT_VERSIONS[rawService]}.`, + suggestion: `Pass --service-version ${rawService}=${defaultVersionsForRuntime(runtime)[rawService]}.`, }), ); } - overrides[rawService] = normalizeServiceVersion(rawService, rawVersion); + overrides[rawService] = normalizeServiceVersion(rawService, rawVersion, runtime); } return overrides; @@ -68,16 +69,18 @@ export const parseServiceVersionOverrides = Effect.fnUntraced(function* ( export const resolveServiceVersionContext = Effect.fnUntraced(function* ( rawOverrides: ReadonlyArray, - pinnedBaselineOverride?: VersionManifest, + pinnedBaselineOverride?: Partial>, + runtime?: VersionRuntime, ) { const projectLinkState = yield* ProjectLinkState; const cliProjectLocalServiceVersions = yield* CliProjectLocalServiceVersions; - const flagOverrides = yield* parseServiceVersionOverrides(rawOverrides); + const flagOverrides = yield* parseServiceVersionOverrides(rawOverrides, runtime); const localState = yield* cliProjectLocalServiceVersions.load; const linkedState = yield* projectLinkState.load; return planStackVersions({ + runtime, candidateBaseline: Option.match(linkedState, { onNone: () => undefined, onSome: (state) => state.versions, diff --git a/packages/process-compose/src/HealthProbe.ts b/packages/process-compose/src/HealthProbe.ts index 3cfcef817d..2e3a4803dd 100644 --- a/packages/process-compose/src/HealthProbe.ts +++ b/packages/process-compose/src/HealthProbe.ts @@ -1,4 +1,8 @@ import * as Net from "node:net"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- Fetch/undici rewrites the Host header; health probes with explicit hosts use the native transport. +import * as Http from "node:http"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- Fetch/undici rewrites the Host header; health probes with explicit hosts use the native transport. +import * as Https from "node:https"; import { Duration, Effect, Match, Ref, Schedule } from "effect"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -13,12 +17,77 @@ const executeProbe = ( ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient > => { return Match.valueTags(probe, { - Http: (probe) => - HttpClient.get(`${probe.scheme}://${probe.host}:${probe.port}${probe.path}`).pipe( + Http: (probe) => { + const hasExplicitHost = Object.keys(probe.headers ?? {}).some( + (header) => header.toLowerCase() === "host", + ); + + if (hasExplicitHost) { + return Effect.callback((resume, signal) => { + let clientRequest: Http.ClientRequest | undefined; + let cleanedUp = false; + + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + signal.removeEventListener("abort", onAbort); + clientRequest?.removeListener("response", onResponse); + clientRequest?.removeListener("error", onError); + clientRequest?.removeListener("abort", onAbortRequest); + clientRequest?.destroy(); + }; + const finish = (healthy: boolean) => { + if (cleanedUp) return; + cleanup(); + resume(Effect.succeed(healthy)); + }; + const onResponse = (response: Http.IncomingMessage) => { + const healthy = + response.statusCode !== undefined && + response.statusCode >= 200 && + response.statusCode < 300; + response.resume(); + finish(healthy); + }; + const onError = () => finish(false); + const onAbortRequest = () => finish(false); + const onAbort = () => cleanup(); + + try { + const url = new URL(`${probe.scheme}://${probe.host}:${probe.port}${probe.path}`); + const options: Http.RequestOptions = { + hostname: url.hostname, + port: url.port, + path: `${url.pathname}${url.search}`, + method: "GET", + headers: probe.headers, + }; + clientRequest = + probe.scheme === "https" ? Https.request(options) : Http.request(options); + clientRequest.once("response", onResponse); + clientRequest.once("error", onError); + clientRequest.once("abort", onAbortRequest); + signal.addEventListener("abort", onAbort, { once: true }); + clientRequest.end(); + } catch { + finish(false); + } + + return Effect.sync(cleanup); + }).pipe( + Effect.timeout(Duration.seconds(timeoutSeconds)), + Effect.orElseSucceed(() => false), + ); + } + + return HttpClient.get(`${probe.scheme}://${probe.host}:${probe.port}${probe.path}`, { + headers: probe.headers, + }).pipe( Effect.timeout(Duration.seconds(timeoutSeconds)), Effect.map((res) => res.status >= 200 && res.status < 300), Effect.orElseSucceed(() => false), - ), + ); + }, Exec: (probe) => { const cmd = ChildProcess.make(probe.command, probe.args, { env: probe.env, diff --git a/packages/process-compose/src/HealthProbe.unit.test.ts b/packages/process-compose/src/HealthProbe.unit.test.ts index fbc6f96d36..19aca69de3 100644 --- a/packages/process-compose/src/HealthProbe.unit.test.ts +++ b/packages/process-compose/src/HealthProbe.unit.test.ts @@ -4,6 +4,8 @@ import { tmpdir } from "node:os"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- This test uses native path handling for its temporary fixture. import { join } from "node:path"; import * as Net from "node:net"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- This test needs a real HTTP server to verify request headers. +import * as Http from "node:http"; import { describe, expect, it } from "@effect/vitest"; import { layer as BunChildProcessSpawnerLayer } from "@effect/platform-bun/BunChildProcessSpawner"; import { layer as BunFileSystemLayer } from "@effect/platform-bun/BunFileSystem"; @@ -119,6 +121,58 @@ describe("HealthProbe", () => { ); }); + it.live("passes configured headers to HTTP probes", () => { + return Effect.gen(function* () { + const server = Http.createServer((request, response) => { + if (request.headers.host === "realtime-dev") { + response.writeHead(200).end(); + } else { + response.writeHead(404).end(); + } + }); + const port = yield* Effect.callback((resume) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address !== null && typeof address !== "string") { + resume(Effect.succeed(address.port)); + } + }); + return Effect.sync(() => server.close()); + }); + + const fetch = globalThis.fetch; + const fetchWithoutHost = Object.assign( + (input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.delete("host"); + // oxlint-disable-next-line effecttsgo/global-fetch -- This test double models Node's fetch transport, which ignores explicit Host headers. + return fetch(input, { ...init, headers }); + }, + { preconnect: fetch.preconnect }, + ); + + const { healthySignal, config, isHealthy } = yield* setupProbe({ + _tag: "Http", + scheme: "http", + host: "127.0.0.1", + port, + path: "/api/ping", + headers: { Host: "realtime-dev" }, + }); + const fiber = yield* Effect.forkChild( + runHealthProbe(config).pipe( + Effect.provide(Layer.succeed(FetchHttpClient.Fetch, fetchWithoutHost)), + ), + ); + yield* Deferred.await(healthySignal).pipe(Effect.timeout(Duration.seconds(5))); + expect(isHealthy()).toBe(true); + yield* Fiber.interrupt(fiber); + yield* Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }); + }).pipe(Effect.provide(platformLayer)); + }); + it.live("Exec probes require explicit args", () => Effect.sync(() => { // @ts-expect-error Exec probes must declare args explicitly. diff --git a/packages/process-compose/src/LogBuffer.ts b/packages/process-compose/src/LogBuffer.ts index 3992f0d10e..9b1cfa65ce 100644 --- a/packages/process-compose/src/LogBuffer.ts +++ b/packages/process-compose/src/LogBuffer.ts @@ -7,6 +7,10 @@ export interface LogEntry { readonly line: string; } +export type LogBufferInternalEvent = + | { readonly _tag: "Entry"; readonly entry: LogEntry } + | { readonly _tag: "Control"; readonly id: string }; + const MAX_BUFFER_SIZE = 10_000; export class LogBuffer extends Context.Service< @@ -17,8 +21,11 @@ export class LogBuffer extends Context.Service< stream: "stdout" | "stderr", line: string, ) => Effect.Effect; + /** Publishes an ordered control event to internal consumers only. */ + readonly appendControl: (id: string) => Effect.Effect; readonly subscribe: (service: string) => Stream.Stream; readonly subscribeAll: Stream.Stream; + readonly subscribeAllInternal: Stream.Stream; readonly history: (service: string, limit?: number) => Effect.Effect>; readonly historyAll: ( limit?: number, @@ -34,8 +41,10 @@ export class LogBuffer extends Context.Service< // Log delivery must never block child stdout/stderr draining. The bounded history refs // below remain authoritative; live subscribers receive the newest entries when slow. const globalPubSub = yield* PubSub.sliding(4096); + const internalPubSub = yield* PubSub.unbounded(); const globalBuffer = yield* Ref.make>([]); const serviceInitialization = Semaphore.makeUnsafe(1); + const publicationLock = Semaphore.makeUnsafe(1); const getOrCreate = (service: string) => Effect.gen(function* () { @@ -62,6 +71,7 @@ export class LogBuffer extends Context.Service< const { pubsub, buffer } = yield* getOrCreate(service); yield* PubSub.publish(pubsub, entry); yield* PubSub.publish(globalPubSub, entry); + yield* PubSub.publish(internalPubSub, { _tag: "Entry", entry }); yield* Ref.update(globalBuffer, (buf) => { const next = buf.concat(entry); return next.length > MAX_BUFFER_SIZE ? next.slice(-MAX_BUFFER_SIZE) : next; @@ -70,7 +80,13 @@ export class LogBuffer extends Context.Service< const next = buf.concat(entry); return next.length > MAX_BUFFER_SIZE ? next.slice(-MAX_BUFFER_SIZE) : next; }); - }), + }).pipe(publicationLock.withPermit), + + appendControl: (id) => + PubSub.publish(internalPubSub, { + _tag: "Control", + id, + }).pipe(publicationLock.withPermit), subscribe: (service) => Stream.unwrap( @@ -81,6 +97,7 @@ export class LogBuffer extends Context.Service< ), subscribeAll: Stream.fromPubSub(globalPubSub), + subscribeAllInternal: Stream.fromPubSub(internalPubSub), history: (service, limit = 100) => Effect.gen(function* () { diff --git a/packages/process-compose/src/LogBuffer.unit.test.ts b/packages/process-compose/src/LogBuffer.unit.test.ts index dee6de6547..96834fa654 100644 --- a/packages/process-compose/src/LogBuffer.unit.test.ts +++ b/packages/process-compose/src/LogBuffer.unit.test.ts @@ -86,6 +86,30 @@ describe("LogBuffer", () => { }).pipe(Effect.provide(layer)), ); + it.live("keeps ordered controls internal to the log buffer", () => + Effect.gen(function* () { + const log = yield* LogBuffer; + const publicFiber = yield* Effect.forkChild( + log.subscribeAll.pipe(Stream.take(1), Stream.runCollect), + { startImmediately: true }, + ); + const internalFiber = yield* Effect.forkChild( + log.subscribeAllInternal.pipe(Stream.take(2), Stream.runCollect), + { startImmediately: true }, + ); + + yield* log.append("svc", "stdout", "entry"); + yield* log.appendControl("shutdown"); + + const publicEntries = yield* Fiber.join(publicFiber); + const internalEvents = yield* Fiber.join(internalFiber); + expect(publicEntries.map((entry) => entry.line)).toEqual(["entry"]); + expect(internalEvents.map((event) => event._tag)).toEqual(["Entry", "Control"]); + expect(internalEvents[1]).toEqual({ _tag: "Control", id: "shutdown" }); + expect((yield* log.historyAll()).map((entry) => entry.line)).toEqual(["entry"]); + }).pipe(Effect.provide(layer)), + ); + it.live("multiple services are independent", () => Effect.gen(function* () { const log = yield* LogBuffer; diff --git a/packages/process-compose/src/Orchestrator.integration.test.ts b/packages/process-compose/src/Orchestrator.integration.test.ts index d9e7d5d30c..b1f0f46f59 100644 --- a/packages/process-compose/src/Orchestrator.integration.test.ts +++ b/packages/process-compose/src/Orchestrator.integration.test.ts @@ -74,6 +74,63 @@ const waitForStatuses = ( }); describe("Orchestrator integration", () => { + it.live( + "applies a POSIX nofile limit without changing command arguments", + () => { + const specialArgument = "argument with spaces;$(not-a-command)"; + const resultFile = `/tmp/pc-nofile-${randomUUID()}.txt`; + const script = [ + "soft=$(ulimit -n)", + "hard=$(ulimit -Hn)", + 'printf "%s\\n%s\\n%s\\n" "$soft" "$hard" "$2" > "$1"', + "exec sleep 60", + ].join("; "); + const defs: ServiceDef[] = [ + { + name: "nofile-service", + command: "sh", + args: ["-c", script, "nofile-service", resultFile, specialArgument], + posixResourceLimits: { nofileSoft: 4096 }, + supervision: {}, + restart: "no", + healthCheck: { + probe: fileExistsProbe(resultFile), + periodSeconds: 0.1, + timeoutSeconds: 2, + failureThreshold: 3, + }, + }, + ]; + const { layer } = setupReal(defs); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const orc = yield* Orchestrator; + yield* orc.start(); + yield* waitForStatuses(orc, [ + { name: "nofile-service", predicate: (state) => state.status === "Healthy" }, + ]); + + const [soft, hard, argument] = (yield* fs.readFileString(resultFile)).trim().split("\n"); + const expectedSoft = hard === "unlimited" ? 4096 : Math.min(4096, Number(hard)); + expect(Number(soft)).toBe(expectedSoft); + expect(argument).toBe(specialArgument); + + yield* orc.stop; + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(resultFile, { force: true }); + }).pipe(Effect.provide(BunFileSystemLayer), Effect.ignore), + ), + Effect.provide(Layer.mergeAll(layer, BunFileSystemLayer)), + Effect.scoped, + ); + }, + { timeout: 15000 }, + ); + it.live( "serializes a concurrent start and stop lifecycle command", () => { diff --git a/packages/process-compose/src/Orchestrator.ts b/packages/process-compose/src/Orchestrator.ts index bff9e6e90f..fd07f6c8c3 100644 --- a/packages/process-compose/src/Orchestrator.ts +++ b/packages/process-compose/src/Orchestrator.ts @@ -34,6 +34,7 @@ import type { import { defaults } from "./ServiceDef.ts"; import { initial, ServiceState, type ServiceDesiredState } from "./ServiceState.ts"; import { makeSupervisedCommand, usesSupervisor } from "./Supervisor.ts"; +import { makePosixNofileLimitedCommand } from "./PosixResourceLimits.ts"; import { CyclicDependencyError, MissingDependencyError, @@ -45,6 +46,17 @@ import { type ServiceEvent, transition } from "./ServiceTransition.ts"; const DIAGNOSTIC_LOG_LINES = 20; +const withPosixNofileLimit = ( + command: ChildProcess.StandardCommand, + requested: number, +): ChildProcess.StandardCommand => { + const wrapped = makePosixNofileLimitedCommand(command.command, command.args, requested); + return ChildProcess.make(wrapped.command, wrapped.args, { + ...command.options, + shell: false, + }); +}; + const willRestartAfterExit = (def: ServiceDef, state: ServiceState): boolean => { if (state.exitCode === null) return false; return Predicate.isTagged( @@ -325,8 +337,24 @@ export class Orchestrator extends Context.Service< const generationResult = Deferred.makeUnsafe(); const supervised = usesSupervisor(def); + const limited = def.posixResourceLimits; + if (limited !== undefined) { + if (!Number.isSafeInteger(limited.nofileSoft) || limited.nofileSoft <= 0) { + return yield* new SpawnError({ + service: def.name, + cause: new Error("POSIX nofile soft limit must be a positive safe integer"), + }); + } + if (process.platform === "win32") { + return yield* new SpawnError({ + service: def.name, + cause: new Error("POSIX resource limits are unavailable on Windows"), + }); + } + } + // Build command - const cmd = supervised + const command = supervised ? makeSupervisedCommand(def) : ChildProcess.make(def.command, def.args ?? [], { cwd: def.cwd, @@ -334,6 +362,10 @@ export class Orchestrator extends Context.Service< extendEnv: true, stdin: "ignore", }); + const cmd = + limited === undefined || supervised + ? command + : withPosixNofileLimit(command, limited.nofileSoft); // Release external resources such as port reservations only // once dependencies are satisfied and spawning is imminent. @@ -343,22 +375,6 @@ export class Orchestrator extends Context.Service< // explicit supervisor/child handshake. yield* options?.beforeSpawn?.(def.name) ?? Effect.void; - // Spawn the process - const handle = yield* spawner - .spawn(cmd) - .pipe( - Effect.mapError((cause) => new SpawnError({ service: def.command, cause })), - ); - - const waitForHandleExit = handle.exitCode.pipe(Effect.asVoid, Effect.ignore); - - const sendSignal = (signal: ChildProcess.Signal): Effect.Effect => - handle - .kill({ killSignal: signal }) - .pipe(Effect.asVoid, Effect.ignore, Effect.andThen(waitForHandleExit)); - - forceStops.set(def.name, sendSignal("SIGKILL")); - const runCleanup = () => def.cleanup == null ? Effect.void @@ -372,17 +388,27 @@ export class Orchestrator extends Context.Service< ), ); - // Register finalizer: graceful shutdown then SIGKILL fallback. - // Tree teardown for supervised services is owned by the supervisor. - yield* Effect.addFinalizer(() => - sendSignal(def.shutdown?.signal ?? defaults.shutdown.signal).pipe( + const sendSignal = ( + handle: ChildProcessSpawner.ChildProcessHandle, + signal: ChildProcess.Signal, + ): Effect.Effect => + handle + .kill({ killSignal: signal }) + .pipe( + Effect.asVoid, + Effect.ignore, + Effect.andThen(handle.exitCode.pipe(Effect.asVoid, Effect.ignore)), + ); + + const releaseHandle = (handle: ChildProcessSpawner.ChildProcessHandle) => + sendSignal(handle, def.shutdown?.signal ?? defaults.shutdown.signal).pipe( Effect.timeout( Duration.seconds( def.shutdown?.timeoutSeconds ?? defaults.shutdown.timeoutSeconds, ), ), Effect.catch(() => - sendSignal("SIGKILL").pipe( + sendSignal(handle, "SIGKILL").pipe( Effect.andThen( logBuffer.append( def.name, @@ -395,7 +421,28 @@ export class Orchestrator extends Context.Service< Effect.ignore, Effect.andThen(runCleanup()), Effect.ensuring(Effect.sync(() => forceStops.delete(def.name))), - ), + ); + + // Acquire the process and register its complete cleanup before allowing + // interruption. The acquisition stays interruptible while the successful + // acquisition-to-registration handoff is atomic. + const handle = yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const spawnedHandle = yield* restore( + spawner + .spawn(cmd) + .pipe( + Effect.mapError( + (cause) => new SpawnError({ service: def.command, cause }), + ), + ), + ); + yield* Effect.addFinalizer(() => releaseHandle(spawnedHandle)); + yield* Effect.sync(() => + forceStops.set(def.name, sendSignal(spawnedHandle, "SIGKILL")), + ); + return spawnedHandle; + }), ); // Keep the service in Starting until its started hooks pass, diff --git a/packages/process-compose/src/Orchestrator.unit.test.ts b/packages/process-compose/src/Orchestrator.unit.test.ts index f07fa0d54e..5f1292ba9d 100644 --- a/packages/process-compose/src/Orchestrator.unit.test.ts +++ b/packages/process-compose/src/Orchestrator.unit.test.ts @@ -39,8 +39,10 @@ function mockLogBuffer() { entries.push({ service, stream, line }); entryEvents.notify(); }), + appendControl: (_line: string) => Effect.void, subscribe: (_service: string) => Stream.empty, subscribeAll: Stream.empty, + subscribeAllInternal: Stream.empty, history: (service: string, limit = 100) => Effect.gen(function* () { const timestamp = yield* Clock.currentTimeMillis; @@ -97,6 +99,7 @@ interface SpawnOpts { stdout?: string[]; exitDelay?: Duration.Input; getExitDelay?: () => Duration.Input; + onHandleCreated?: (handle: ChildProcessSpawner.ChildProcessHandle) => void; } function createWaitList() { @@ -203,7 +206,7 @@ function mockChildProcessSpawner( const stdoutBytes = (svcOpts.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); - return ChildProcessSpawner.makeHandle({ + const handle = ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1000 + spawned.length), stdout: Stream.fromIterable(stdoutBytes), stderr: Stream.empty, @@ -221,6 +224,8 @@ function mockChildProcessSpawner( getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }); + svcOpts.onHandleCreated?.(handle); + return handle; }), ), ), @@ -341,6 +346,20 @@ const svc = (name: string, overrides?: Partial): ServiceDef => ({ // --- Tests --- describe("Orchestrator", () => { + it.live("keeps a service without resource limits as a direct command", () => { + const args = ["argument with spaces", "$(not-a-command)"]; + const { layer, proc } = setupOrchestrator([ + svc("direct", { command: "service-command", args, restart: "no" }), + ]); + + return Effect.gen(function* () { + const orc = yield* Orchestrator; + yield* orc.start(); + yield* proc.waitForSpawnCount(1); + expect(proc.spawned[0]).toEqual({ command: "service-command", args }); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + it.live("start() spawns all services", () => { const { layer, proc } = setupOrchestrator([svc("a"), svc("b"), svc("c")]); return Effect.gen(function* () { @@ -523,6 +542,34 @@ describe("Orchestrator", () => { }).pipe(Effect.provide(layer), Effect.scoped); }); + it.live("interrupting immediately after process acquisition cleans up the handle", () => { + let triggered = false; + const { layer, proc } = setupOrchestrator([svc("target")], { + exitDelay: "5 seconds", + onHandleCreated: (handle) => { + const exitCode = handle.exitCode; + const fiber = Fiber.getCurrent(); + Object.defineProperty(handle, "exitCode", { + configurable: true, + get: () => { + if (!triggered) { + triggered = true; + fiber?.interruptUnsafe(); + } + return exitCode; + }, + }); + }, + }); + + return Effect.gen(function* () { + const orchestrator = yield* Orchestrator; + yield* orchestrator.startService("target"); + yield* proc.waitForKillCount(1); + expect(proc.killed.some((record) => record.command === "target")).toBe(true); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + it.live("ServiceDef shutdown does not expose killMode", () => { const service: ServiceDef = { name: "a", diff --git a/packages/process-compose/src/PosixResourceLimits.ts b/packages/process-compose/src/PosixResourceLimits.ts new file mode 100644 index 0000000000..49ef0d8368 --- /dev/null +++ b/packages/process-compose/src/PosixResourceLimits.ts @@ -0,0 +1,44 @@ +/** + * Wraps a command in a POSIX shell that sets its soft open-file limit before + * replacing itself with the original process. + * + * Command and arguments are passed as positional parameters. This keeps + * service-provided values out of the shell program and preserves argv exactly. + */ +export const makePosixNofileLimitedCommand = ( + command: string, + args: ReadonlyArray, + requested: number, +): { readonly command: "/bin/sh"; readonly args: ReadonlyArray } => ({ + command: "/bin/sh", + args: [ + "-c", + ` +requested="$1" +shift +hard=$(ulimit -Hn) || { + printf '%s\\n' 'failed to query the POSIX nofile hard limit' >&2 + exit 125 +} +case "$hard" in + unlimited) limit="$requested" ;; + ''|*[!0-9]*) + printf '%s\\n' "invalid POSIX nofile hard limit: $hard" >&2 + exit 125 + ;; + *) + if [ "$hard" -lt "$requested" ]; then limit="$hard"; else limit="$requested"; fi + ;; +esac +if ! ulimit -Sn "$limit"; then + printf '%s\\n' "failed to set the POSIX nofile soft limit to $limit" >&2 + exit 125 +fi +exec "$@" +`, + "process-compose-posix-limit", + String(requested), + command, + ...args, + ], +}); diff --git a/packages/process-compose/src/ServiceDef.ts b/packages/process-compose/src/ServiceDef.ts index 97d012b895..19c738de54 100644 --- a/packages/process-compose/src/ServiceDef.ts +++ b/packages/process-compose/src/ServiceDef.ts @@ -15,6 +15,7 @@ export type ProbeConfig = readonly port: number; readonly path: string; readonly scheme: "http" | "https"; + readonly headers?: Readonly>; } | { readonly _tag: "Exec"; @@ -71,6 +72,12 @@ export interface SupervisionConfig { readonly orphanCleanup?: ReadonlyArray; } +/** Resource limits supported by native POSIX process launches. */ +export interface PosixResourceLimits { + /** Soft open-file limit requested for the service process. */ + readonly nofileSoft: number; +} + export interface ServiceDef { readonly name: string; readonly command: string; @@ -85,6 +92,7 @@ export interface ServiceDef { readonly maxRestarts?: number; readonly cleanup?: Effect.Effect; readonly supervision?: SupervisionConfig; + readonly posixResourceLimits?: PosixResourceLimits; readonly hooks?: ReadonlyArray; readonly enabled?: boolean; } diff --git a/packages/process-compose/src/Supervisor.ts b/packages/process-compose/src/Supervisor.ts index b936e9d02f..ea40d954b1 100644 --- a/packages/process-compose/src/Supervisor.ts +++ b/packages/process-compose/src/Supervisor.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from "node:url"; import { ChildProcess } from "effect/unstable/process"; -import type { ExternalCleanupAction, ServiceDef } from "./ServiceDef.ts"; +import type { ExternalCleanupAction, PosixResourceLimits, ServiceDef } from "./ServiceDef.ts"; import { defaults } from "./ServiceDef.ts"; import { isSupervisorSelfDispatchEnabled, @@ -14,6 +14,7 @@ interface SupervisorRuntimeConfig { readonly shutdownSignal: ChildProcess.Signal; readonly shutdownTimeoutMs: number; readonly cleanup: ReadonlyArray; + readonly posixResourceLimits?: PosixResourceLimits; } export const supervisorRuntimePath = fileURLToPath( @@ -30,6 +31,9 @@ export const makeSupervisedCommand = (def: ServiceDef) => { shutdownSignal: def.shutdown?.signal ?? defaults.shutdown.signal, shutdownTimeoutMs: (def.shutdown?.timeoutSeconds ?? defaults.shutdown.timeoutSeconds) * 1000, cleanup: def.supervision?.orphanCleanup ?? [], + ...(def.posixResourceLimits === undefined + ? {} + : { posixResourceLimits: def.posixResourceLimits }), }; const encoded = Buffer.from(JSON.stringify(runtimeConfig)).toString("base64url"); const selfDispatch = isSupervisorSelfDispatchEnabled(import.meta.url); diff --git a/packages/process-compose/src/SupervisorRuntime.unit.test.ts b/packages/process-compose/src/SupervisorRuntime.unit.test.ts index 5f6d9d5132..c509e8d8fc 100644 --- a/packages/process-compose/src/SupervisorRuntime.unit.test.ts +++ b/packages/process-compose/src/SupervisorRuntime.unit.test.ts @@ -281,6 +281,28 @@ describe("supervisor-runtime", () => { } }); + test("rejects an invalid POSIX nofile limit before spawning", async () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-invalid-limit-")); + const childMarker = path.join(tempDir, "child-started"); + const encodedConfig = Buffer.from( + JSON.stringify({ + command: process.execPath, + args: ["-e", `require("node:fs").writeFileSync(${JSON.stringify(childMarker)}, "started")`], + posixResourceLimits: { nofileSoft: 0 }, + }), + ).toString("base64url"); + const supervisor = spawnSupervisor("source path", encodedConfig); + + try { + await waitFor(() => supervisor.exitCode != null); + expect(supervisor.exitCode).not.toBe(0); + expect(existsSync(childMarker)).toBe(false); + } finally { + supervisor.kill("SIGKILL"); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + test("removes supervisor protocol variables from the managed child environment", () => { const childEnv = withoutSupervisorRuntimeEnv({ KEEP_ME: "value", diff --git a/packages/process-compose/src/index.ts b/packages/process-compose/src/index.ts index 421763613f..d917282e3c 100644 --- a/packages/process-compose/src/index.ts +++ b/packages/process-compose/src/index.ts @@ -10,6 +10,7 @@ export type { HookTrigger, HookLog, LifecycleHook, + PosixResourceLimits, OrchestratorConfig, ServiceStartOptions, ServiceDef, @@ -27,7 +28,7 @@ export { SpawnError, } from "./errors.ts"; -export type { LogEntry } from "./LogBuffer.ts"; +export type { LogBufferInternalEvent, LogEntry } from "./LogBuffer.ts"; export { LogBuffer } from "./LogBuffer.ts"; export type { ResolvedGraph } from "./DependencyGraph.ts"; diff --git a/packages/process-compose/src/supervisor-runtime.ts b/packages/process-compose/src/supervisor-runtime.ts index 3ac0933fbc..f14026453c 100644 --- a/packages/process-compose/src/supervisor-runtime.ts +++ b/packages/process-compose/src/supervisor-runtime.ts @@ -15,7 +15,8 @@ import { Schedule, } from "effect"; import type { ChildProcess } from "effect/unstable/process"; -import type { ExternalCleanupAction } from "./ServiceDef.ts"; +import type { ExternalCleanupAction, PosixResourceLimits } from "./ServiceDef.ts"; +import { makePosixNofileLimitedCommand } from "./PosixResourceLimits.ts"; import { supervisorRuntimeConfigFromEnv, withoutSupervisorRuntimeEnv, @@ -28,6 +29,10 @@ class SupervisorCleanupError extends Data.TaggedError("SupervisorCleanupError")< readonly cause: unknown; }> {} +class SupervisorSpawnError extends Data.TaggedError("SupervisorSpawnError")<{ + readonly cause: unknown; +}> {} + interface SupervisorRuntimeConfig { readonly command: string; readonly args?: ReadonlyArray; @@ -35,6 +40,7 @@ interface SupervisorRuntimeConfig { readonly shutdownSignal?: ChildProcess.Signal; readonly shutdownTimeoutMs?: number; readonly cleanup?: ReadonlyArray; + readonly posixResourceLimits?: PosixResourceLimits; } interface ChildExit { @@ -177,6 +183,14 @@ const cleanupActionsFrom = (value: unknown): ReadonlyArray action != null) ? actions : undefined; }; +const posixResourceLimitsFrom = (value: unknown): PosixResourceLimits | undefined => { + if (!isObject(value)) return undefined; + const nofileSoft = getField(value, "nofileSoft"); + return typeof nofileSoft === "number" && Number.isSafeInteger(nofileSoft) && nofileSoft > 0 + ? { nofileSoft } + : undefined; +}; + const parseSupervisorRuntimeConfig = (encodedConfig: string): SupervisorRuntimeConfig => { const value: unknown = JSON.parse(Buffer.from(encodedConfig, "base64url").toString("utf8")); if (!isObject(value)) { @@ -195,6 +209,14 @@ const parseSupervisorRuntimeConfig = (encodedConfig: string): SupervisorRuntimeC if (cleanupValue !== undefined && cleanup === undefined) { throw new Error("Invalid supervisor cleanup"); } + const posixResourceLimitsValue = getField(value, "posixResourceLimits"); + const posixResourceLimits = + posixResourceLimitsValue === undefined + ? undefined + : posixResourceLimitsFrom(posixResourceLimitsValue); + if (posixResourceLimitsValue !== undefined && posixResourceLimits === undefined) { + throw new Error("Invalid supervisor POSIX resource limits"); + } return { command, @@ -203,6 +225,7 @@ const parseSupervisorRuntimeConfig = (encodedConfig: string): SupervisorRuntimeC shutdownSignal: signalFrom(getField(value, "shutdownSignal")), shutdownTimeoutMs: typeof shutdownTimeoutMs === "number" ? shutdownTimeoutMs : undefined, cleanup, + posixResourceLimits, }; }; @@ -239,18 +262,30 @@ const waitForExit = ( Effect.map(Option.isSome), ); -const runSupervisorRuntimeEffect = (config: SupervisorRuntimeConfig): Effect.Effect => +const runSupervisorRuntimeEffect = ( + config: SupervisorRuntimeConfig, +): Effect.Effect => Effect.scoped( Effect.gen(function* () { const childEnv = withoutSupervisorRuntimeEnv(); - const child = yield* Effect.sync(() => - spawn(config.command, config.args ?? [], { - cwd: process.cwd(), - env: childEnv, - stdio: ["ignore", "pipe", "pipe"], - detached: !isWindows, - }), - ); + const childCommand = + config.posixResourceLimits === undefined + ? { command: config.command, args: config.args ?? [] } + : makePosixNofileLimitedCommand( + config.command, + config.args ?? [], + config.posixResourceLimits.nofileSoft, + ); + const child = yield* Effect.try({ + try: () => + spawn(childCommand.command, childCommand.args, { + cwd: process.cwd(), + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + detached: !isWindows, + }), + catch: (cause) => new SupervisorSpawnError({ cause }), + }); if (child.stdout != null) child.stdout.pipe(process.stdout); if (child.stderr != null) child.stderr.pipe(process.stderr); diff --git a/packages/stack/scripts/sync-versions-from-dockerfile.ts b/packages/stack/scripts/sync-versions-from-dockerfile.ts index 56cfde7634..8eabf80496 100644 --- a/packages/stack/scripts/sync-versions-from-dockerfile.ts +++ b/packages/stack/scripts/sync-versions-from-dockerfile.ts @@ -71,7 +71,7 @@ export function readVersionManifestFromDockerfile(dockerfile: string): VersionMa throw new Error(`Duplicate Dockerfile version for '${service}'.`); } - versions[service] = normalizeServiceVersion(service, tag); + versions[service] = normalizeServiceVersion(service, tag, "docker"); } assertFullManifest(versions); @@ -87,19 +87,24 @@ export function syncDefaultVersionsSource(source: string, versions: VersionManif throw new Error(`Could not find catalog entry for '${service}'.`); } - const versionMarker = " defaultVersion: "; - const versionStart = updated.indexOf(versionMarker, entryStart + nameMarker.length); - if (versionStart === -1 || versionStart - entryStart > 300) { - throw new Error(`Could not find defaultVersion for '${service}'.`); + const defaultsMarker = " defaultVersions:"; + const defaultsStart = updated.indexOf(defaultsMarker, entryStart + nameMarker.length); + if (defaultsStart === -1 || defaultsStart - entryStart > 300) { + throw new Error(`Could not find defaultVersions for '${service}'.`); } - const versionEnd = updated.indexOf("\n", versionStart); - if (versionEnd === -1) { - throw new Error(`Could not find defaultVersion line end for '${service}'.`); + const defaultsEnd = updated.indexOf("},", defaultsStart); + const dockerMarker = "docker: "; + const dockerStart = updated.indexOf(dockerMarker, defaultsStart); + if (defaultsEnd === -1 || dockerStart === -1 || dockerStart > defaultsEnd) { + throw new Error(`Could not find Docker defaultVersion for '${service}'.`); } - - updated = `${updated.slice(0, versionStart)}${versionMarker}${JSON.stringify( - versions[service], - )},${updated.slice(versionEnd)}`; + const valueStart = dockerStart + dockerMarker.length; + const quoteStart = updated.indexOf('"', valueStart); + const quoteEnd = quoteStart === -1 ? -1 : updated.indexOf('"', quoteStart + 1); + if (quoteStart === -1 || quoteEnd === -1) { + throw new Error(`Could not find Docker defaultVersion value for '${service}'.`); + } + updated = `${updated.slice(0, quoteStart + 1)}${versions[service]}${updated.slice(quoteEnd)}`; } return updated; } diff --git a/packages/stack/src/ApiProxy.integration.test.ts b/packages/stack/src/ApiProxy.integration.test.ts new file mode 100644 index 0000000000..693e44c7d0 --- /dev/null +++ b/packages/stack/src/ApiProxy.integration.test.ts @@ -0,0 +1,328 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-fetch, effecttsgo/new-promise, effecttsgo/node-builtin-import -- The proxy integration test owns native HTTP/WebSocket listeners and exercises the public network surface. +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; +import { Buffer } from "node:buffer"; +import * as http from "node:http"; +import { Deferred, Effect, Layer, ManagedRuntime, Predicate } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import { createClient } from "@supabase/supabase-js"; +import { describe, expect, test } from "vitest"; +import { ApiProxy, type ProxyConfig } from "./ApiProxy.ts"; +import { StackServiceActivator } from "./ServiceActivation.ts"; + +const PUBLISHABLE_KEY = "sb_publishable_testkey"; + +interface WebSocketBackend { + readonly port: number; + readonly request: () => { readonly url: string; readonly host: string | undefined } | undefined; + readonly messageIsBinary: () => boolean | undefined; + readonly stop: () => Promise; +} + +const rawDataToUtf8 = (data: NodeSocket.NodeWS.RawData): string => { + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); + return Buffer.from(data).toString("utf8"); +}; + +function startWebSocketBackend( + onMessage?: (websocket: NodeSocket.NodeWS.WebSocket, data: NodeSocket.NodeWS.RawData) => void, +): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer(); + const webSocketServer = new NodeSocket.NodeWS.WebSocketServer({ + noServer: true, + handleProtocols: (protocols) => protocols.values().next().value ?? false, + }); + let requestDetails: { readonly url: string; readonly host: string | undefined } | undefined; + let messageIsBinary: boolean | undefined; + const clients = new Set(); + + server.on("upgrade", (request, socket, head) => { + webSocketServer.handleUpgrade(request, socket, head, (websocket) => { + clients.add(websocket); + websocket.once("close", () => clients.delete(websocket)); + requestDetails = { url: request.url ?? "", host: request.headers.host }; + websocket.on("message", (data, isBinary) => { + messageIsBinary = isBinary; + if (onMessage === undefined) { + websocket.send(data, { binary: isBinary }); + } else { + onMessage(websocket, data); + } + }); + }); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Unexpected server address")); + return; + } + resolve({ + port: address.port, + request: () => requestDetails, + messageIsBinary: () => messageIsBinary, + stop: () => + new Promise((res, rej) => { + for (const client of clients) client.terminate(); + webSocketServer.close((error) => { + if (error) { + rej(error); + return; + } + server.close((closeError) => (closeError ? rej(closeError) : res())); + }); + }), + }); + }); + }); +} + +function buildProxyLayer(config: ProxyConfig): Layer.Layer { + return ApiProxy.layer(config).pipe( + Layer.provide(NodeHttpServer.layer(() => http.createServer(), { port: 0 }).pipe(Layer.orDie)), + Layer.provide(FetchHttpClient.layer), + Layer.provide(StackServiceActivator.noop), + ) as Layer.Layer; +} + +function buildBunProxyLayer( + config: ProxyConfig, + activator: Layer.Layer = StackServiceActivator.noop, +): Layer.Layer { + return ApiProxy.layer(config).pipe( + Layer.provide(BunHttpServer.layer({ hostname: "127.0.0.1", port: 0 })), + Layer.provide(FetchHttpClient.layer), + Layer.provide(activator), + ) as Layer.Layer; +} + +const proxyConfigFor = (backendPort: number): ProxyConfig => ({ + listenPort: 0, + gotruePort: backendPort, + postgrestPort: backendPort, + postgrestAdminPort: backendPort, + edgeRuntimePort: backendPort, + realtimePort: backendPort, + realtimeTenantId: "realtime-test", + storagePort: backendPort, + pgmetaPort: backendPort, + analyticsPort: backendPort, + poolerPort: backendPort, + studioPort: backendPort, + publishableKey: PUBLISHABLE_KEY, + secretKey: "sb_secret_testkey", + anonJwt: "test-anon-jwt-token", + serviceRoleJwt: "test-service-role-jwt-token", +}); + +const clientAddress = (port: number): string => + `ws://127.0.0.1:${port}/realtime/v1/websocket?apikey=${PUBLISHABLE_KEY}&vsn=1.0.0`; + +const awaitClientOpen = (client: NodeSocket.NodeWS.WebSocket): Promise => + new Promise((resolve, reject) => { + client.once("open", resolve); + client.once("error", reject); + }); + +const awaitClientClose = ( + client: NodeSocket.NodeWS.WebSocket, +): Promise<{ readonly code: number; readonly reason: string }> => + new Promise((resolve, reject) => { + client.once("close", (code, reason) => resolve({ code, reason: rawDataToUtf8(reason) })); + client.once("error", reject); + }); + +async function openNodeProxyWebSocket(onMessage?: Parameters[0]) { + const backend = await startWebSocketBackend(onMessage); + const runtime = ManagedRuntime.make(buildProxyLayer(proxyConfigFor(backend.port))); + const proxy = await runtime.runPromise(ApiProxy); + const address = proxy.address; + if (!Predicate.isTagged(address, "TcpAddress")) throw new Error("Expected TCP proxy address"); + const client = new NodeSocket.NodeWS.WebSocket(clientAddress(address.port), "realtime-v1"); + return { + backend, + runtime, + client, + dispose: async () => { + if (client.readyState !== NodeSocket.NodeWS.WebSocket.CLOSED) client.terminate(); + await runtime.dispose(); + await backend.stop(); + }, + }; +} + +describe("ApiProxy realtime websocket", () => { + test("forwards frames with the backend path, tenant host, and projected query key", async () => { + const fixture = await openNodeProxyWebSocket(); + const payload = '["hello realtime"]'; + try { + await awaitClientOpen(fixture.client); + const echoed = new Promise<{ readonly text: string; readonly isBinary: boolean }>( + (resolve, reject) => { + fixture.client.once("message", (data, isBinary) => + resolve({ text: rawDataToUtf8(data), isBinary }), + ); + fixture.client.once("error", reject); + }, + ); + fixture.client.send(payload); + + expect(await echoed).toEqual({ text: payload, isBinary: false }); + expect(fixture.backend.messageIsBinary()).toBe(false); + expect(fixture.backend.request()).toEqual({ + url: "/socket/websocket?apikey=test-anon-jwt-token&vsn=1.0.0", + host: "realtime-test", + }); + } finally { + await fixture.dispose(); + } + }); + + test( + "forwards a clean backend close code and reason to the client", + { timeout: 5_000 }, + async () => { + const fixture = await openNodeProxyWebSocket((websocket) => { + websocket.close(1000, "backend shutdown"); + }); + try { + const closed = awaitClientClose(fixture.client); + await awaitClientOpen(fixture.client); + fixture.client.send("close"); + + await expect(closed).resolves.toEqual({ code: 1000, reason: "backend shutdown" }); + } finally { + await fixture.dispose(); + } + }, + ); + + test( + "forwards an application backend close code and reason to the client", + { timeout: 5_000 }, + async () => { + const fixture = await openNodeProxyWebSocket((websocket) => { + websocket.close(1013, "service restarting"); + }); + try { + const closed = awaitClientClose(fixture.client); + await awaitClientOpen(fixture.client); + fixture.client.send("close"); + + await expect(closed).resolves.toEqual({ code: 1013, reason: "service restarting" }); + } finally { + await fixture.dispose(); + } + }, + ); + + test( + "normalizes an unsendable backend close code before forwarding it", + { timeout: 5_000 }, + async () => { + const fixture = await openNodeProxyWebSocket((websocket) => { + websocket.terminate(); + }); + try { + const closed = awaitClientClose(fixture.client); + await awaitClientOpen(fixture.client); + fixture.client.send("close"); + + await expect(closed).resolves.toEqual({ + code: 1011, + reason: "realtime backend connection lost", + }); + } finally { + await fixture.dispose(); + } + }, + ); + + (typeof Bun === "undefined" ? test.skip : test)( + "forwards a Phoenix join from supabase-js after delayed Bun activation", + { timeout: 15_000 }, + async () => { + const activationGate = Deferred.makeUnsafe(); + let joinReceived: (() => void) | undefined; + const join = new Promise((resolve) => { + joinReceived = resolve; + }); + const backend = await startWebSocketBackend((websocket, data) => { + const message = JSON.parse(rawDataToUtf8(data)) as [ + string | null, + string | null, + string, + string, + { readonly config?: { readonly postgres_changes?: unknown } }, + ]; + if (message[3] !== "phx_join") return; + joinReceived?.(); + websocket.send( + JSON.stringify([ + message[0], + message[1], + message[2], + "phx_reply", + { response: {}, status: "ok" }, + ]), + ); + }); + const config = proxyConfigFor(backend.port); + let transportConnected: (() => void) | undefined; + const transport = new Promise((resolve) => { + transportConnected = resolve; + }); + const runtime = ManagedRuntime.make( + buildBunProxyLayer( + config, + Layer.succeed(StackServiceActivator, { + activate: () => Deferred.await(activationGate), + }), + ), + ); + const proxy = await runtime.runPromise(ApiProxy); + const address = proxy.address; + if (!Predicate.isTagged(address, "TcpAddress")) throw new Error("Expected TCP proxy address"); + const client = createClient(`http://127.0.0.1:${address.port}`, PUBLISHABLE_KEY, { + realtime: { + timeout: 2_000, + logger: (kind, message) => { + if (kind === "transport" && message.startsWith("connected to")) { + transportConnected?.(); + transportConnected = undefined; + } + }, + }, + }); + let channel: ReturnType | undefined; + try { + const subscribed = new Promise((resolve, reject) => { + channel = client.channel("bun-public-surface").subscribe((status) => { + if (status === "SUBSCRIBED") resolve(); + else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") { + reject(new Error(`Unexpected realtime status: ${status}`)); + } + }); + }); + await transport; + expect(backend.request()).toBeUndefined(); + Deferred.doneUnsafe(activationGate, Effect.void); + await join; + await subscribed; + expect(backend.request()).toEqual({ + url: "/socket/websocket?apikey=test-anon-jwt-token&vsn=2.0.0", + host: "realtime-test", + }); + } finally { + if (channel !== undefined) await client.removeChannel(channel); + await client.removeAllChannels(); + await runtime.dispose(); + await backend.stop(); + } + }, + ); +}); diff --git a/packages/stack/src/ApiProxy.ts b/packages/stack/src/ApiProxy.ts index 08e49149cc..f239c06d82 100644 --- a/packages/stack/src/ApiProxy.ts +++ b/packages/stack/src/ApiProxy.ts @@ -1,4 +1,19 @@ -import { Deferred, Effect, Layer, Option, Context, Predicate, Schedule, Result } from "effect"; +import { + Context, + Data, + Deferred, + Effect, + Fiber, + FiberSet, + Layer, + Latch, + Option, + Predicate, + Result, + Schedule, + Scope, +} from "effect"; +import * as NodeSocket from "@effect/platform-node/NodeSocket"; import { Headers, HttpBody, @@ -9,6 +24,7 @@ import { HttpServerRequest, HttpServerResponse, } from "effect/unstable/http"; +import * as Socket from "effect/unstable/socket/Socket"; import { StackServiceActivator } from "./ServiceActivation.ts"; import type { ServiceName } from "./ServiceName.ts"; @@ -19,6 +35,7 @@ export interface ProxyConfig { readonly postgrestAdminPort: number; readonly edgeRuntimePort: number; readonly realtimePort: number; + readonly realtimeTenantId: string; readonly storagePort: number; readonly pgmetaPort: number; readonly analyticsPort: number; @@ -30,6 +47,436 @@ export interface ProxyConfig { readonly serviceRoleJwt: string; } +class RealtimeWebSocketProxyError extends Data.TaggedError("RealtimeWebSocketProxyError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +class RealtimeBackendSocketClosed extends Data.TaggedError("RealtimeBackendSocketClosed")<{ + readonly closeEvent: Socket.CloseEvent; +}> {} + +function transformRealtimeWebSocketUrl(requestUrl: string, config: ProxyConfig): string { + const stripped = requestUrl.startsWith("/realtime/v1") + ? requestUrl.slice("/realtime/v1".length) + : requestUrl; + const relative = stripped === "" ? "/" : stripped; + const url = new URL(relative, `ws://127.0.0.1:${config.realtimePort}`); + url.pathname = `/socket${url.pathname}`; + + const apikey = url.searchParams.get("apikey"); + if (apikey === config.publishableKey) { + url.searchParams.set("apikey", config.anonJwt); + } else if (apikey === config.secretKey) { + url.searchParams.set("apikey", config.serviceRoleJwt); + } + + return url.toString(); +} + +const isSendableWebSocketCloseCode = (code: number): boolean => + Number.isInteger(code) && + ((code >= 1000 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) || + (code >= 3000 && code <= 4999)); + +const backendCloseEvent = (error: Socket.SocketError): Socket.CloseEvent | undefined => { + if (!Predicate.isTagged(error.reason, "SocketCloseError")) return undefined; + + const { code, closeReason } = error.reason; + if (code === 1005) return new Socket.CloseEvent(1000, closeReason ?? ""); + if (code === 1006) return new Socket.CloseEvent(1011, "realtime backend connection lost"); + if ( + !isSendableWebSocketCloseCode(code) || + (closeReason !== undefined && Buffer.byteLength(closeReason, "utf8") > 123) + ) { + return new Socket.CloseEvent(1011, "realtime backend sent an invalid close frame"); + } + + return new Socket.CloseEvent(code, closeReason); +}; + +const backendWriterCloseEvent = (error: Socket.SocketError): Socket.CloseEvent => + backendCloseEvent(error) ?? new Socket.CloseEvent(1011, "realtime backend connection lost"); + +function websocketMessageChunk( + data: NodeSocket.NodeWS.RawData, + isBinary: boolean, +): string | Uint8Array { + if (!isBinary) { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data)); + if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); + return data.toString("utf8"); + } + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data)); + return data; +} + +function realtimeClientFrame(chunk: string | Uint8Array): string | Uint8Array { + if (typeof chunk === "string") return chunk; + + let offset = 0; + while ( + offset < chunk.length && + (chunk[offset] === 0x09 || + chunk[offset] === 0x0a || + chunk[offset] === 0x0d || + chunk[offset] === 0x20) + ) { + offset += 1; + } + + return chunk[offset] === 0x5b ? new TextDecoder().decode(chunk) : chunk; +} + +function makeNodeWebSocketSocket( + websocket: NodeSocket.NodeWS.WebSocket, +): Effect.Effect { + return Effect.withFiber(() => { + const latch = Latch.makeUnsafe(false); + let currentWebSocket: NodeSocket.NodeWS.WebSocket | undefined; + + const runRaw = <_, E, R>( + handler: (_: string | Uint8Array) => Effect.Effect<_, E, R> | void, + options?: { readonly onOpen?: Effect.Effect | undefined }, + ) => + Effect.scopedWith( + Effect.fnUntraced(function* (scope) { + const fiberSet = yield* FiberSet.make().pipe( + Scope.provide(scope), + ); + const run = yield* FiberSet.runtime(fiberSet)(); + let open = websocket.readyState === NodeSocket.NodeWS.WebSocket.OPEN; + + const onMessage = (data: NodeSocket.NodeWS.RawData, isBinary: boolean) => { + const chunk = websocketMessageChunk(data, isBinary); + const result = handler(chunk); + if (Effect.isEffect(result)) { + run(result); + } + }; + const onError = (cause: Error) => { + websocket.off("message", onMessage); + websocket.off("close", onClose); + Deferred.doneUnsafe( + fiberSet.deferred, + Effect.fail( + new Socket.SocketError({ + reason: open + ? new Socket.SocketReadError({ cause }) + : new Socket.SocketOpenError({ kind: "Unknown", cause }), + }), + ), + ); + }; + const onClose = (code: number, reason: Buffer) => { + websocket.off("message", onMessage); + websocket.off("error", onError); + Deferred.doneUnsafe( + fiberSet.deferred, + Effect.fail( + new Socket.SocketError({ + reason: new Socket.SocketCloseError({ + code, + closeReason: reason.toString(), + }), + }), + ), + ); + }; + + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + websocket.off("message", onMessage); + websocket.off("error", onError); + websocket.off("close", onClose); + if (currentWebSocket === websocket) { + currentWebSocket = undefined; + latch.closeUnsafe(); + } + }), + ); + + websocket.on("message", onMessage); + websocket.once("error", onError); + websocket.once("close", onClose); + + if (!open) { + const opened = Deferred.makeUnsafe(); + const onSocketOpen = () => { + open = true; + Deferred.doneUnsafe(opened, Effect.void); + }; + websocket.once("open", onSocketOpen); + yield* Deferred.await(opened).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => + Effect.fail( + new Socket.SocketError({ + reason: new Socket.SocketOpenError({ + kind: "Timeout", + cause: new Error("timeout waiting for realtime websocket"), + }), + }), + ), + }), + Effect.raceFirst(FiberSet.join(fiberSet)), + ); + } + + currentWebSocket = websocket; + latch.openUnsafe(); + if (options?.onOpen) { + yield* options.onOpen; + } + return yield* FiberSet.join(fiberSet); + }), + ).pipe( + Effect.ensuring( + Effect.sync(() => { + latch.closeUnsafe(); + currentWebSocket = undefined; + }), + ), + ); + + const writer = Effect.acquireRelease( + Effect.succeed((chunk: Uint8Array | string | Socket.CloseEvent) => + latch.whenOpen( + Effect.suspend(() => { + try { + const active = currentWebSocket; + if (active === undefined) { + return Effect.fail( + new Socket.SocketError({ + reason: new Socket.SocketWriteError({ + cause: new Error("realtime websocket is not open"), + }), + }), + ); + } + if (Socket.isCloseEvent(chunk)) { + active.close(chunk.code, chunk.reason); + } else { + active.send(chunk); + } + return Effect.void; + } catch (cause) { + return Effect.fail( + new Socket.SocketError({ reason: new Socket.SocketWriteError({ cause }) }), + ); + } + }), + ), + ), + () => Effect.sync(() => currentWebSocket?.close(1000)), + ); + + return Effect.succeed(Socket.make({ runRaw, writer })); + }); +} + +function openRealtimeWebSocket( + requestUrl: string, + config: ProxyConfig, + protocols: Array, +): Effect.Effect { + return Effect.callback((resume, signal) => { + const backendUrl = transformRealtimeWebSocketUrl(requestUrl, config); + const websocket = new NodeSocket.NodeWS.WebSocket(backendUrl, protocols, { + headers: { host: config.realtimeTenantId }, + }); + const onOpen = () => { + websocket.off("error", onError); + signal.removeEventListener("abort", onAbort); + resume(Effect.succeed(websocket)); + }; + const onError = (cause: Error) => { + resume( + Effect.fail( + new Socket.SocketError({ + reason: new Socket.SocketOpenError({ kind: "Unknown", cause }), + }), + ), + ); + }; + const onAbort = () => websocket.terminate(); + + websocket.once("open", onOpen); + websocket.once("error", onError); + signal.addEventListener("abort", onAbort, { once: true }); + + return Effect.sync(() => { + signal.removeEventListener("abort", onAbort); + websocket.off("open", onOpen); + websocket.off("error", onError); + if ( + websocket.readyState === NodeSocket.NodeWS.WebSocket.OPEN || + websocket.readyState === NodeSocket.NodeWS.WebSocket.CONNECTING + ) { + websocket.terminate(); + } + }); + }); +} + +function makeRealtimeWebSocket( + requestUrl: string, + config: ProxyConfig, + protocols: Array, +): Effect.Effect { + return Effect.acquireRelease(openRealtimeWebSocket(requestUrl, config, protocols), (websocket) => + Effect.sync(() => { + if ( + websocket.readyState === NodeSocket.NodeWS.WebSocket.OPEN || + websocket.readyState === NodeSocket.NodeWS.WebSocket.CONNECTING + ) { + websocket.terminate(); + } + }), + ).pipe(Effect.flatMap(makeNodeWebSocketSocket)); +} + +function makeRealtimeWebSocketHandler( + config: ProxyConfig, + activator: StackServiceActivator["Service"], + signalTerminalFailure: Effect.Effect, + runBridge: (effect: Effect.Effect) => Fiber.Fiber, +) { + return (req: HttpServerRequest.HttpServerRequest) => + Effect.gen(function* () { + const protocolHeader = req.headers["sec-websocket-protocol"]; + const protocols = + protocolHeader === undefined + ? [] + : protocolHeader + .split(",") + .map((protocol) => protocol.trim()) + .filter((protocol) => protocol !== ""); + const clientSocket = yield* req.upgrade.pipe( + Effect.mapError( + (error) => + new RealtimeWebSocketProxyError({ + message: error.message, + cause: error, + }), + ), + ); + // Return as soon as the upgrade is accepted. The bridge is kept in the + // layer-owned FiberSet so it outlives request completion and can cleanly + // close both sockets when the stack shuts down. + const bridge = Effect.scoped( + Effect.gen(function* () { + const clientWriter = + yield* Deferred.make< + ( + chunk: Uint8Array | string | Socket.CloseEvent, + ) => Effect.Effect + >(); + const backendWriter = + yield* Deferred.make< + ( + chunk: Uint8Array | string | Socket.CloseEvent, + ) => Effect.Effect + >(); + + const clientToBackend = clientSocket + .runRaw((chunk) => + Deferred.await(backendWriter).pipe( + Effect.flatMap((write) => write(realtimeClientFrame(chunk))), + Effect.catchTag("SocketError", (error) => + Effect.fail( + new RealtimeBackendSocketClosed({ + closeEvent: backendWriterCloseEvent(error), + }), + ), + ), + ), + ) + .pipe( + Effect.catchTags({ + RealtimeBackendSocketClosed: ({ closeEvent }) => Effect.succeed(closeEvent), + SocketError: (error) => + Predicate.isTagged(error.reason, "SocketCloseError") + ? Effect.void + : Effect.fail(error), + }), + ); + const clientFiber = yield* clientToBackend.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(clientWriter, yield* clientSocket.writer); + + const closeClient = (event: Socket.CloseEvent) => + Deferred.await(clientWriter).pipe( + Effect.flatMap((write) => write(event)), + Effect.catchTag("SocketError", () => Effect.void), + ); + const activation = yield* activator.activate("realtime").pipe( + Effect.tapErrorTag("StackReadinessError", () => signalTerminalFailure), + Effect.result, + ); + if (Result.isFailure(activation)) { + yield* closeClient(new Socket.CloseEvent(1013, "realtime unavailable")); + return; + } + + const backendResult = yield* makeRealtimeWebSocket(req.url, config, protocols).pipe( + Effect.mapError( + (error) => + new RealtimeWebSocketProxyError({ + message: error.message, + cause: error, + }), + ), + Effect.result, + ); + if (Result.isFailure(backendResult)) { + yield* closeClient(new Socket.CloseEvent(1011, "realtime proxy error")); + return; + } + const backendSocket = backendResult.success; + const backendToClient = backendSocket + .runRaw((chunk) => + Effect.flatMap(Deferred.await(clientWriter), (write) => write(chunk)), + ) + .pipe( + Effect.catchTag("SocketError", (error) => { + const closeEvent = backendCloseEvent(error); + return closeEvent === undefined ? Effect.fail(error) : Effect.succeed(closeEvent); + }), + ); + const bridge = Effect.race(Fiber.join(clientFiber), backendToClient).pipe( + Effect.mapError((error) => + error instanceof RealtimeWebSocketProxyError + ? error + : new RealtimeWebSocketProxyError({ + message: error.message, + cause: error, + }), + ), + ); + yield* Deferred.succeed(backendWriter, yield* backendSocket.writer); + const bridgeResult = yield* bridge.pipe(Effect.result); + if (Result.isSuccess(bridgeResult) && Socket.isCloseEvent(bridgeResult.success)) { + yield* closeClient(bridgeResult.success); + } else if (Result.isFailure(bridgeResult)) { + yield* closeClient(new Socket.CloseEvent(1011, "realtime proxy error")); + } + }), + ); + runBridge(bridge); + return HttpServerResponse.empty(); + }).pipe( + Effect.catchTag("RealtimeWebSocketProxyError", (error) => + Effect.succeed(HttpServerResponse.text(`Bad gateway: ${error.message}`, { status: 502 })), + ), + ); +} + function transformAuthorization( headers: Headers.Headers, config: ProxyConfig, @@ -238,6 +685,24 @@ export class ApiProxy extends Context.Service< const activator = yield* StackServiceActivator; const terminalFailure = yield* Deferred.make(); const signalTerminalFailure = Deferred.succeed(terminalFailure, void 0).pipe(Effect.asVoid); + const realtimeBridges = yield* FiberSet.makeRuntime(); + const realtimeHttpHandler = makeProxyHandler( + client, + config, + activator, + signalTerminalFailure, + { + service: "realtime", + backendPort: config.realtimePort, + stripPrefix: "/realtime/v1", + }, + ); + const realtimeWebSocketHandler = makeRealtimeWebSocketHandler( + config, + activator, + signalTerminalFailure, + realtimeBridges, + ); const routes = [ HttpRouter.route("*", "/health", HttpServerResponse.text("OK", { status: 200 })), @@ -339,15 +804,12 @@ export class ApiProxy extends Context.Service< transformAuth: true, }), ), - HttpRouter.route( - "*", - "/realtime/v1/*", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "realtime", - backendPort: config.realtimePort, - stripPrefix: "/realtime/v1", - }), + HttpRouter.route("GET", "/realtime/v1/websocket", (req) => + req.headers.upgrade?.toLowerCase() === "websocket" + ? realtimeWebSocketHandler(req) + : realtimeHttpHandler(req), ), + HttpRouter.route("*", "/realtime/v1/*", realtimeHttpHandler), HttpRouter.route( "*", "/storage/v1/s3/*", diff --git a/packages/stack/src/ApiProxy.unit.test.ts b/packages/stack/src/ApiProxy.unit.test.ts index cf031ba0cd..8b8f7f072d 100644 --- a/packages/stack/src/ApiProxy.unit.test.ts +++ b/packages/stack/src/ApiProxy.unit.test.ts @@ -162,6 +162,7 @@ describe("ApiProxy", () => { postgrestAdminPort: echoPort, edgeRuntimePort: echoPort, realtimePort: echoPort, + realtimeTenantId: "realtime-dev", storagePort: echoPort, pgmetaPort: echoPort, analyticsPort: echoPort, @@ -427,6 +428,7 @@ describe("ApiProxy", () => { postgrestAdminPort: deadPort, edgeRuntimePort: deadPort, realtimePort: deadPort, + realtimeTenantId: "realtime-dev", storagePort: deadPort, pgmetaPort: deadPort, analyticsPort: deadPort, @@ -470,6 +472,7 @@ describe("ApiProxy", () => { postgrestAdminPort: port, edgeRuntimePort: port, realtimePort: port, + realtimeTenantId: "realtime-dev", storagePort: port, pgmetaPort: port, analyticsPort: port, diff --git a/packages/stack/src/BeamCookie.ts b/packages/stack/src/BeamCookie.ts new file mode 100644 index 0000000000..8cf41379c2 --- /dev/null +++ b/packages/stack/src/BeamCookie.ts @@ -0,0 +1,15 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- Native BEAM releases require cryptographically random stack identity. +import { randomBytes } from "node:crypto"; + +/** + * Native BEAM services pass this value through PostgreSQL channel identifiers as + * well as Erlang distribution cookies. Keep the random portion large enough to + * prevent collisions while staying within PostgreSQL's 63-byte identifier cap. + */ +const BEAM_COOKIE_RANDOM_BYTES = 23; +const BEAM_COOKIE_PREFIX = "supabase_"; +const BEAM_COOKIE_SUFFIX = "_cookie"; + +/** @internal Generate the per-stack cookie shared by native BEAM services. */ +export const generateBeamReleaseCookie = (): string => + `${BEAM_COOKIE_PREFIX}${randomBytes(BEAM_COOKIE_RANDOM_BYTES).toString("hex")}${BEAM_COOKIE_SUFFIX}`; diff --git a/packages/stack/src/BeamCookie.unit.test.ts b/packages/stack/src/BeamCookie.unit.test.ts new file mode 100644 index 0000000000..b8754708ed --- /dev/null +++ b/packages/stack/src/BeamCookie.unit.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { generateBeamReleaseCookie } from "./BeamCookie.ts"; + +describe("generateBeamReleaseCookie", () => { + it("produces a random cookie that fits every native consumer's identifier limits", () => { + const cookie = generateBeamReleaseCookie(); + + expect(cookie).toMatch(/^supabase_[a-f0-9]{46}_cookie$/); + expect(Buffer.byteLength(cookie, "utf8")).toBeLessThanOrEqual(63); + expect(generateBeamReleaseCookie()).not.toBe(cookie); + }); +}); diff --git a/packages/stack/src/BinaryResolver.integration.test.ts b/packages/stack/src/BinaryResolver.integration.test.ts index 357faaca01..caac5c2d4a 100644 --- a/packages/stack/src/BinaryResolver.integration.test.ts +++ b/packages/stack/src/BinaryResolver.integration.test.ts @@ -433,6 +433,44 @@ describe("BinaryResolver slim-services installer", () => { }), ); + it.live("accepts static-musl manifests without requiring a glibc host", () => + Effect.gen(function* () { + const root = makeRoot(); + try { + const fixture = makeFixture(root, { + runtime_requires: null, + libc: "musl", + os_floor: { + kind: "glibc", + floor: null, + offender: null, + scanned: 1, + bundled_glibc: false, + }, + }); + const resolverLayer = makeResolverLayer(root, fixture); + const result = yield* Effect.gen(function* () { + const resolver = yield* BinaryResolver; + const first = yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + const second = yield* resolver.resolveWithMetadata({ + service: "postgrest", + version: DEFAULT_VERSIONS.postgrest, + }); + return { first, second }; + }).pipe(Effect.provide(resolverLayer)); + + expect(result.first.downloaded).toBe(true); + expect(result.second.downloaded).toBe(false); + expect(existsSync(join(result.second.path, "bin/postgrest"))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }), + ); + it.effect("reclaims interrupted staging while preserving complete cache entries", () => Effect.gen(function* () { const root = makeRoot(); diff --git a/packages/stack/src/BinaryResolver.ts b/packages/stack/src/BinaryResolver.ts index ef03aafd2c..7658fd9979 100644 --- a/packages/stack/src/BinaryResolver.ts +++ b/packages/stack/src/BinaryResolver.ts @@ -46,7 +46,7 @@ interface SlimServiceManifest { readonly entrypoint: ReadonlyArray; readonly cmd: ReadonlyArray; readonly runtime_requires?: null | "glibc"; - readonly libc: null | "glibc"; + readonly libc: null | "glibc" | "musl"; readonly os_floor: null | { readonly kind: string; readonly floor: string | null; @@ -70,7 +70,7 @@ interface AssetInfo { interface HostCompatibilityRequirement { readonly runtimeRequires: null | "glibc"; - readonly libc: null | "glibc"; + readonly libc: null | "glibc" | "musl"; readonly osFloor: null | { readonly kind: "glibc" | "macos"; readonly floor: string | null; @@ -185,7 +185,11 @@ const isSlimServiceManifest = (value: unknown): value is SlimServiceManifest => value.runtime_requires !== "glibc" ) return false; - if (!("libc" in value) || (value.libc !== null && value.libc !== "glibc")) return false; + if ( + !("libc" in value) || + (value.libc !== null && value.libc !== "glibc" && value.libc !== "musl") + ) + return false; if (!("os_floor" in value)) return false; if (value.os_floor !== null) { if (typeof value.os_floor !== "object") return false; @@ -292,7 +296,7 @@ const validateHostCompatibility = ( const requiresGlibc = requirement.libc === "glibc" || requirement.runtimeRequires === "glibc" || - requirement.osFloor?.kind === "glibc"; + (requirement.osFloor?.kind === "glibc" && requirement.osFloor.floor !== null); if (requirement.osFloor?.kind === "macos" && platform.os !== "darwin") { return yield* new BinaryHostCompatibilityError({ target, @@ -481,7 +485,11 @@ export class BinaryResolver extends Context.Service< (host.runtimeRequires !== null && host.runtimeRequires !== "glibc") ) return false; - if (!("libc" in host) || (host.libc !== null && host.libc !== "glibc")) return false; + if ( + !("libc" in host) || + (host.libc !== null && host.libc !== "glibc" && host.libc !== "musl") + ) + return false; if (!("osFloor" in host)) return false; if (host.osFloor !== null) { if (typeof host.osFloor !== "object") return false; diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts index 9f631fa37d..a175e74684 100644 --- a/packages/stack/src/LocalStack.ts +++ b/packages/stack/src/LocalStack.ts @@ -27,6 +27,7 @@ import { import { ChildProcessSpawner } from "effect/unstable/process"; import { FetchHttpClient } from "effect/unstable/http"; import type { CleanupTargets } from "./CleanupTargets.ts"; +import { generateBeamReleaseCookie } from "./BeamCookie.ts"; import { cleanupLocalStackResources } from "./cleanup.ts"; import { DockerPullError, @@ -41,6 +42,7 @@ import { type ResolvedFunctionsBundle, } from "./functions.ts"; import { detectPlatform, dockerHostAddress } from "./Platform.ts"; +import type { PortField } from "./PortCatalog.ts"; import type { PortLease } from "./PortAllocator.ts"; import { activationReadinessPolicy, @@ -64,6 +66,7 @@ import { projectStackStates, type StackServiceProjectionCatalog } from "./StackS import { StackServiceState } from "./StackServiceState.ts"; import { Stack } from "./Stack.ts"; import type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts"; +import { startNativeLogWriter, type NativeLogWriter } from "./NativeLogWriter.ts"; import { SERVICE_NAMES, type ServiceName } from "./versions.ts"; type LifecyclePhase = "idle" | "starting" | "running" | "stopping" | "stopped" | "disposed"; @@ -73,6 +76,15 @@ type StackService = typeof Stack.Service; const READINESS_DIAGNOSTIC_LOG_LIMIT = 20; const READINESS_DIAGNOSTIC_LINE_LIMIT = 512; +/** Pooler bootstrap briefly binds its private shard listeners before the public server starts. */ +const portFieldsForDefinition = ( + name: string, + mode: ResolvedStackConfig["runtime"]["mode"], +): ReadonlyArray => + name === "pooler-bootstrap" && mode === "native" + ? ["poolerInternalPort"] + : portFieldsForService(name, mode); + /** @internal Enriches a readiness timeout without changing diagnostic failure semantics. */ export const attachReadinessDiagnostics = ( error: StackReadinessError, @@ -184,7 +196,11 @@ const stackInfoFor = (config: ResolvedStackConfig): StackInfo => { ...(config.pooler === false ? {} : { - pooler: `postgresql://postgres:postgres@127.0.0.1:${config.pooler.port}/postgres`, + pooler: `postgresql://postgres.${encodeURIComponent(config.pooler.tenantId)}:postgres@127.0.0.1:${ + config.pooler.mode === "session" + ? config.pooler.sessionPort + : config.pooler.transactionPort + }/postgres`, pooler_admin: `http://127.0.0.1:${config.pooler.apiPort}`, }), }, @@ -244,6 +260,13 @@ export const localStackLayer = ( const disposedSignal = yield* Deferred.make(); const lifecycleLock = Semaphore.makeUnsafe(1); const projectionLock = Semaphore.makeUnsafe(1); + // One stack-owned cookie is shared by all native BEAM services and remains stable across + // JIT preparation, restarts, and definition rebuilds for this LocalStack instance. + const beamReleaseCookie = yield* Effect.try({ + try: generateBeamReleaseCookie, + catch: (cause) => + new StackBuildError({ detail: "Failed to generate native BEAM release cookie", cause }), + }); const logBufferServices = yield* Layer.buildWithScope(LogBuffer.layer, scope); const logBuffer = Context.get(logBufferServices, LogBuffer); @@ -324,6 +347,7 @@ export const localStackLayer = ( let runtimeState: RuntimeState | undefined; let runtimeDeferred: Deferred.Deferred | undefined; let exactCleanupTargets: CleanupTargets | undefined; + let nativeLogWriter: NativeLogWriter | undefined; const preparationInput = ( services: ReadonlyArray, @@ -494,7 +518,7 @@ export const localStackLayer = ( const effect = Effect.gen(function* () { const prepared = yield* ensurePlanned; const { graph, serviceProjection, cleanupTargets } = yield* builder - .build(config, prepared) + .build(config, prepared, { beamReleaseCookie }) .pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Scope.Scope, scope), @@ -510,6 +534,13 @@ export const localStackLayer = ( } exactCleanupTargets = cleanupTargets; + if (config.runtime.mode === "native") { + nativeLogWriter = yield* startNativeLogWriter(logBuffer, config.runtimeRoot).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Scope.Scope, scope), + ); + } + const orchLayer = Orchestrator.layer(graph).pipe( Layer.provide(Layer.succeed(LogBuffer, logBuffer)), Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), @@ -536,6 +567,16 @@ export const localStackLayer = ( runtimeState = value; }), ), + Effect.ensuring( + Effect.suspend(() => { + if (runtimeState === undefined && nativeLogWriter !== undefined) { + const writer = nativeLogWriter; + nativeLogWriter = undefined; + return writer.shutdown.pipe(Effect.ignore); + } + return Effect.void; + }), + ), Effect.ensuring( Effect.sync(() => { runtimeDeferred = undefined; @@ -653,9 +694,10 @@ export const localStackLayer = ( // Reservation may yield while disposal flips the lifecycle state. beforeStart: (name: string) => portLease - .reserve(portFieldsForService(name)) + .reserve(portFieldsForDefinition(name, config.runtime.mode)) .pipe(Effect.andThen(requireMutable(`start service ${name}`))), - beforeSpawn: (name: string) => portLease.release(portFieldsForService(name)), + beforeSpawn: (name: string) => + portLease.release(portFieldsForDefinition(name, config.runtime.mode)), }; const knownServiceError = (service: string, cause: ServiceNotFoundError) => new StackBuildError({ @@ -807,6 +849,7 @@ export const localStackLayer = ( Effect.provideService(FileSystem.FileSystem, fs), Effect.ensuring(providePlatform(clearFunctionsRuntimeConfig(config.runtimeRoot))), Effect.ensuring(portLease.releaseAll), + Effect.ensuring(Effect.suspend(() => nativeLogWriter?.shutdown ?? Effect.void)), Effect.ensuring(Ref.set(phaseRef, "disposed")), ); }).pipe(withLifecycleLock); @@ -1120,7 +1163,7 @@ export const localStackLayer = ( const prepared = yield* ensurePlanned; const runtime = yield* ensureRuntime; const buildResult = yield* builder - .build(nextConfig, prepared) + .build(nextConfig, prepared, { beamReleaseCookie }) .pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Scope.Scope, scope), diff --git a/packages/stack/src/NativeLogWriter.integration.test.ts b/packages/stack/src/NativeLogWriter.integration.test.ts new file mode 100644 index 0000000000..c50f89950c --- /dev/null +++ b/packages/stack/src/NativeLogWriter.integration.test.ts @@ -0,0 +1,773 @@ +// oxlint-disable effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json -- Integration coverage uses the real platform filesystem and inspects JSONL journal records. + +import { describe, expect } from "vitest"; +import { it } from "@effect/vitest"; +import { NodeFileSystem } from "@effect/platform-node"; +import { + Cause, + Context, + Deferred, + Duration, + Effect, + Exit, + FileSystem, + Fiber, + Layer, + PlatformError, + PubSub, + Scope, + Stream, +} from "effect"; +import { LogBuffer } from "@supabase/process-compose"; +import { join } from "node:path"; +import { nativeLogRoot, nativeServiceLogPath, startNativeLogWriter } from "./NativeLogWriter.ts"; + +const withLogBuffer = ( + f: ( + logBuffer: LogBuffer["Service"], + fs: FileSystem.FileSystem, + root: string, + sibling: string, + signal: PubSub.PubSub, + ) => Effect.Effect, +) => + Effect.scoped( + Effect.gen(function* () { + const signal = yield* PubSub.unbounded(); + const fs = yield* FileSystem.FileSystem.pipe(Effect.provide(journalFileSystemLayer(signal))); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-logs-" }); + const sibling = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-native-logs-sibling-", + }); + const scope = yield* Effect.scope; + const services = yield* Layer.buildWithScope(LogBuffer.layer, scope); + return yield* f(Context.get(services, LogBuffer), fs, root, sibling, signal); + }), + ).pipe(Effect.provide(NodeFileSystem.layer)); + +const awaitDirectoryState = ( + fs: FileSystem.FileSystem, + root: string, + expected: Readonly boolean)>>, + trigger: Effect.Effect = Effect.void, + signal?: PubSub.PubSub, + ready?: Deferred.Deferred, +): Effect.Effect => + Effect.gen(function* () { + const subscription = signal === undefined ? undefined : yield* PubSub.subscribe(signal); + if (ready !== undefined) yield* Deferred.succeed(ready, undefined); + yield* trigger; + const expectedNames = Object.keys(expected).toSorted(); + while (true) { + const names = (yield* fs.readDirectory(root)).toSorted(); + if ( + names.length === expectedNames.length && + names.every((name, index) => name === expectedNames[index]) + ) { + const contents = yield* Effect.forEach(expectedNames, (name) => + fs.readFileString(join(root, name)).pipe(Effect.orElseSucceed(() => undefined)), + ); + if ( + contents.every((content, index) => { + if (content === undefined) return false; + const name = expectedNames[index]; + if (name === undefined) return false; + const expectedContent = expected[name]; + return typeof expectedContent === "function" + ? expectedContent(content) + : content === expectedContent; + }) + ) { + return; + } + } + if (subscription === undefined) return yield* Effect.never; + const changedPath = yield* PubSub.take(subscription); + if (!changedPath.startsWith(`${root}/`)) continue; + } + }).pipe(Effect.timeout(Duration.seconds(5))); + +const journalFileSystemLayer = ( + signal: PubSub.PubSub, + base: Layer.Layer = NodeFileSystem.layer, +) => + Layer.effect( + FileSystem.FileSystem, + Effect.map( + FileSystem.FileSystem, + (fileSystem) => + ({ + ...fileSystem, + open: (path: string, options?: Parameters[1]) => + fileSystem.open(path, options).pipe( + Effect.map( + (file) => + ({ + [FileSystem.FileTypeId]: file[FileSystem.FileTypeId], + get stat() { + return file.stat; + }, + seek: (offset, from) => file.seek(offset, from), + get sync() { + return file.sync; + }, + read: (buffer) => file.read(buffer), + readAlloc: (size) => file.readAlloc(size), + truncate: (length) => file.truncate(length), + write: (buffer) => file.write(buffer), + writeAll: (buffer) => + file.writeAll(buffer).pipe(Effect.tap(() => PubSub.publish(signal, path))), + }) satisfies FileSystem.File, + ), + ), + }) satisfies FileSystem.FileSystem, + ), + ).pipe(Layer.provide(base)); + +const flakyWriteFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (base) => { + let failNextOpen = true; + return { + ...base, + open: (path: string, options?: Parameters[1]) => + failNextOpen + ? Effect.sync(() => { + failNextOpen = false; + }).pipe( + Effect.andThen( + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "test", + method: "open", + description: "injected transient open failure", + }), + ), + ), + ) + : base.open(path, options), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeFileSystem.layer)); + +const flakyRenameFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (base) => { + let failNextRename = true; + return { + ...base, + rename: (from: string, to: string) => + failNextRename + ? Effect.sync(() => { + failNextRename = false; + }).pipe( + Effect.andThen( + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "test", + method: "rename", + description: "injected transient rename failure", + }), + ), + ), + ) + : base.rename(from, to), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeFileSystem.layer)); + +const persistentWriteFailureFileSystem = ( + gates: Partial<{ + readonly authWriteStarted: Deferred.Deferred; + readonly authFailureDone: Deferred.Deferred; + readonly postgresWritten: Deferred.Deferred; + readonly releaseAuth: Deferred.Deferred; + }> = {}, +) => { + let failingAuthWrites = true; + let authOpens = 0; + let authFailures = 0; + const layer = Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (base) => { + return { + ...base, + open: (path: string, options?: Parameters[1]) => + base.open(path, options).pipe( + Effect.map((file) => { + if (path.endsWith("auth.jsonl")) authOpens += 1; + return { + [FileSystem.FileTypeId]: file[FileSystem.FileTypeId], + get stat() { + return file.stat; + }, + seek: (offset, from) => file.seek(offset, from), + get sync() { + return file.sync; + }, + read: (buffer) => file.read(buffer), + readAlloc: (size) => file.readAlloc(size), + truncate: (length) => file.truncate(length), + write: (buffer) => file.write(buffer), + writeAll: (buffer) => + path.endsWith("auth.jsonl") && failingAuthWrites + ? (gates.authWriteStarted === undefined + ? Effect.void + : Deferred.succeed(gates.authWriteStarted, undefined).pipe(Effect.asVoid) + ).pipe( + Effect.andThen( + gates.releaseAuth === undefined + ? Effect.void + : Deferred.await(gates.releaseAuth), + ), + Effect.andThen( + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "test", + method: "writeAll", + description: "injected persistent write failure", + }), + ), + ), + Effect.tapError(() => + Effect.sync(() => (authFailures += 1)).pipe( + Effect.flatMap((count) => + count >= 4 && gates.authFailureDone !== undefined + ? Deferred.succeed(gates.authFailureDone, undefined).pipe( + Effect.asVoid, + ) + : Effect.void, + ), + ), + ), + ) + : file + .writeAll(buffer) + .pipe( + Effect.tap(() => + gates.postgresWritten === undefined + ? Effect.void + : Deferred.succeed(gates.postgresWritten, undefined), + ), + ), + } satisfies FileSystem.File; + }), + ), + } satisfies FileSystem.FileSystem; + }), + ).pipe(Layer.provide(NodeFileSystem.layer)); + return { + layer, + recover: () => { + failingAuthWrites = false; + }, + get authOpens() { + return authOpens; + }, + get authFailures() { + return authFailures; + }, + }; +}; + +describe("native log writer", () => { + it.live("journals each LogBuffer entry below only its owning runtime root", () => + withLogBuffer((logBuffer, fs, root, sibling, signal) => + Effect.gen(function* () { + yield* startNativeLogWriter(logBuffer, root).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ); + const authPath = nativeServiceLogPath(root, "auth"); + + yield* awaitDirectoryState( + fs, + nativeLogRoot(root), + { + ["auth.jsonl"]: (content) => content.includes('"message":"authenticated"'), + }, + logBuffer.append("auth", "stdout", "authenticated"), + signal, + ); + + const contents = yield* fs.readFileString(authPath); + const [event] = contents + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(event).toMatchObject({ + service: "auth", + stream: "stdout", + message: "authenticated", + }); + expect(typeof event.timestamp).toBe("number"); + expect(yield* fs.exists(nativeServiceLogPath(root, "postgres"))).toBe(false); + expect(nativeLogRoot(root)).toContain(root); + expect(yield* fs.exists(nativeLogRoot(sibling))).toBe(false); + }), + ), + ); + + it.live("rotates only owned segments and releases handles when its scope closes", () => + Effect.scoped( + Effect.gen(function* () { + const signal = yield* PubSub.unbounded(); + const fs = yield* FileSystem.FileSystem.pipe( + Effect.provide(journalFileSystemLayer(signal)), + ); + const root = yield* fs.makeTempDirectory({ prefix: "supabase-native-logs-" }); + const logRoot = nativeLogRoot(root); + yield* fs.makeDirectory(logRoot, { recursive: true }); + const sentinelPath = join(logRoot, "unrelated.sentinel"); + yield* fs.writeFileString(sentinelPath, "keep"); + const writerScope = yield* Scope.make("sequential"); + const services = yield* Layer.build(LogBuffer.layer); + const logBuffer = Context.get(services, LogBuffer); + + yield* startNativeLogWriter(logBuffer, root).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Scope.Scope, writerScope), + ); + const message = "x".repeat(30_000); + for (let index = 0; index < 8; index += 1) { + const names = + index < 2 + ? ["unrelated.sentinel", "auth.jsonl"] + : index < 4 + ? ["unrelated.sentinel", "auth.jsonl", "auth.jsonl.1"] + : ["unrelated.sentinel", "auth.jsonl", "auth.jsonl.1", "auth.jsonl.2"]; + yield* awaitDirectoryState( + fs, + nativeLogRoot(root), + Object.fromEntries( + names.map((name) => [ + name, + name === "unrelated.sentinel" + ? "keep" + : name === "auth.jsonl" + ? (content: string) => content.includes(`"message":"${index}:${message}"`) + : (content: string) => content.split("\n").filter(Boolean).length >= 1, + ]), + ), + logBuffer.append("auth", "stdout", `${index}:${message}`), + signal, + ); + } + + const paths = yield* fs.readDirectory(nativeLogRoot(root)); + expect(paths.toSorted()).toEqual([ + "auth.jsonl", + "auth.jsonl.1", + "auth.jsonl.2", + "unrelated.sentinel", + ]); + for (const path of paths) { + if (path === "unrelated.sentinel") continue; + const contents = yield* fs.readFileString(join(nativeLogRoot(root), path)); + const lines = contents.trim().split("\n").filter(Boolean); + expect(lines.length).toBeLessThanOrEqual(1_000); + expect(new TextEncoder().encode(contents).byteLength).toBeLessThanOrEqual(64 * 1024); + for (const line of lines) { + expect(JSON.parse(line)).toMatchObject({ service: "auth", stream: "stdout" }); + } + } + expect(yield* fs.readFileString(sentinelPath)).toBe("keep"); + + yield* Scope.close(writerScope, Exit.void); + const beforeCloseAppend = yield* fs.readFileString(nativeServiceLogPath(root, "auth")); + yield* logBuffer.append("auth", "stdout", "after-close"); + expect(yield* fs.readFileString(nativeServiceLogPath(root, "auth"))).toBe( + beforeCloseAppend, + ); + yield* fs.rename( + nativeServiceLogPath(root, "auth"), + nativeServiceLogPath(root, "auth") + ".closed", + ); + yield* fs.remove(root, { recursive: true, force: true }); + expect(yield* fs.exists(root)).toBe(false); + }), + ).pipe(Effect.provide(NodeFileSystem.layer)), + ); + + it.live("keeps arbitrary service names inside the owning log root", () => + Effect.scoped( + Effect.gen(function* () { + const signal = yield* PubSub.unbounded(); + const fs = yield* FileSystem.FileSystem.pipe( + Effect.provide(journalFileSystemLayer(signal)), + ); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-logs-" }); + const services = yield* Layer.build(LogBuffer.layer); + const logBuffer = Context.get(services, LogBuffer); + yield* startNativeLogWriter(logBuffer, root).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ); + + const malicious = "../escaped-service"; + yield* awaitDirectoryState( + fs, + nativeLogRoot(root), + { + [`${encodeURIComponent(malicious)}.jsonl`]: (content) => + content.includes('"message":"contained"'), + }, + logBuffer.append(malicious, "stderr", "contained"), + signal, + ); + expect(yield* fs.exists(join(root, "escaped-service.jsonl"))).toBe(false); + }), + ).pipe(Effect.provide(NodeFileSystem.layer)), + ); + + it.live("retries a transient journal write without losing the event", () => + Effect.scoped( + Effect.gen(function* () { + const signal = yield* PubSub.unbounded(); + const fs = yield* FileSystem.FileSystem.pipe( + Effect.provide(journalFileSystemLayer(signal, flakyWriteFileSystemLayer)), + ); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-logs-" }); + const services = yield* Layer.build(LogBuffer.layer); + const logBuffer = Context.get(services, LogBuffer); + yield* startNativeLogWriter(logBuffer, root).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ); + yield* awaitDirectoryState( + fs, + nativeLogRoot(root), + { + ["auth.jsonl"]: (content) => content.includes('"message":"recovered"'), + }, + logBuffer.append("auth", "stdout", "recovered"), + signal, + ); + }), + ).pipe(Effect.provide(NodeFileSystem.layer)), + ); + + it.live("drains teardown entries before direct scope disposal closes the writer", () => + Effect.gen(function* () { + const signal = yield* PubSub.unbounded(); + const fs = yield* FileSystem.FileSystem.pipe(Effect.provide(journalFileSystemLayer(signal))); + const root = yield* fs.makeTempDirectory({ prefix: "supabase-native-logs-" }); + const writerScope = yield* Scope.make("sequential"); + const services = yield* Layer.build(LogBuffer.layer); + const logBuffer = Context.get(services, LogBuffer); + yield* startNativeLogWriter(logBuffer, root).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Scope.Scope, writerScope), + ); + yield* Scope.addFinalizer(writerScope, logBuffer.append("auth", "stderr", "shutdown-entry")); + + yield* Scope.close(writerScope, Exit.void); + + expect(yield* fs.readFileString(nativeServiceLogPath(root, "auth"))).toContain( + '"message":"shutdown-entry"', + ); + yield* fs.remove(root, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeFileSystem.layer)), + ); + + it.live("isolates a blocked journal from a healthy sibling", () => + Effect.gen(function* () { + const signal = yield* PubSub.unbounded(); + const authWriteStarted = yield* Deferred.make(); + const authFailureDone = yield* Deferred.make(); + const postgresWritten = yield* Deferred.make(); + const releaseAuth = yield* Deferred.make(); + const injected = persistentWriteFailureFileSystem({ + authWriteStarted, + authFailureDone, + postgresWritten, + releaseAuth, + }); + const fs = yield* FileSystem.FileSystem.pipe( + Effect.provide(journalFileSystemLayer(signal, injected.layer)), + ); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-logs-" }); + const services = yield* Layer.build(LogBuffer.layer); + const realLogBuffer = Context.get(services, LogBuffer); + const logBuffer = { + ...realLogBuffer, + subscribeAllInternal: Stream.make( + { + _tag: "Entry" as const, + entry: { timestamp: 0, service: "auth", stream: "stdout" as const, line: "failed" }, + }, + { + _tag: "Entry" as const, + entry: { + timestamp: 0, + service: "postgres", + stream: "stdout" as const, + line: "healthy", + }, + }, + ), + }; + yield* startNativeLogWriter(logBuffer, root).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ); + + yield* Effect.gen(function* () { + yield* Deferred.await(authWriteStarted); + // The auth write is still blocked at this point, so postgres can only + // reach this gate when per-service flushing is isolated. + yield* Deferred.await(postgresWritten); + yield* Deferred.succeed(releaseAuth, undefined); + yield* Deferred.await(authFailureDone); + }).pipe(Effect.timeout(Duration.seconds(5))); + expect(yield* fs.readFileString(nativeServiceLogPath(root, "postgres"))).toContain( + '"message":"healthy"', + ); + expect(yield* fs.readFileString(nativeServiceLogPath(root, "auth"))).toBe(""); + expect(injected.authOpens).toBeGreaterThan(1); + }).pipe(Effect.provide(NodeFileSystem.layer)), + ); + + it.live("reopens a failed service after persistent write failure clears", () => + Effect.gen(function* () { + const signal = yield* PubSub.unbounded(); + const injected = persistentWriteFailureFileSystem(); + const fs = yield* FileSystem.FileSystem.pipe( + Effect.provide(journalFileSystemLayer(signal, injected.layer)), + ); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-logs-" }); + const services = yield* Layer.build(LogBuffer.layer); + const logBuffer = Context.get(services, LogBuffer); + yield* startNativeLogWriter(logBuffer, root).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ); + + yield* awaitDirectoryState( + fs, + nativeLogRoot(root), + { + ["auth.jsonl"]: () => true, + ["postgres.jsonl"]: (content) => content.includes('"message":"healthy"'), + }, + Effect.gen(function* () { + yield* logBuffer.append("auth", "stdout", "failed"); + yield* logBuffer.append("postgres", "stdout", "healthy"); + }), + signal, + ); + injected.recover(); + yield* awaitDirectoryState( + fs, + nativeLogRoot(root), + { + ["auth.jsonl"]: (content) => content.includes('"message":"recovered"'), + ["postgres.jsonl"]: (content) => content.includes('"message":"healthy"'), + }, + logBuffer.append("auth", "stdout", "recovered"), + signal, + ); + expect(injected.authOpens).toBeGreaterThan(1); + }).pipe(Effect.provide(NodeFileSystem.layer)), + ); + + it.live("recovers rotation after one rename failure and continues journaling", () => + Effect.scoped( + Effect.gen(function* () { + const signal = yield* PubSub.unbounded(); + const fs = yield* FileSystem.FileSystem.pipe( + Effect.provide(journalFileSystemLayer(signal, flakyRenameFileSystemLayer)), + ); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-logs-" }); + const services = yield* Layer.build(LogBuffer.layer); + const logBuffer = Context.get(services, LogBuffer); + const writerScope = yield* Scope.make("sequential"); + yield* startNativeLogWriter(logBuffer, root).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Scope.Scope, writerScope), + ); + const message = "r".repeat(30_000); + + for (let index = 0; index < 4; index += 1) { + yield* awaitDirectoryState( + fs, + nativeLogRoot(root), + { + ["auth.jsonl"]: (content) => content.includes(`"message":"${index}:${message}"`), + ...(index >= 2 + ? { + ["auth.jsonl.1"]: (content: string) => content.length > 0, + } + : {}), + }, + logBuffer.append("auth", "stdout", `${index}:${message}`), + signal, + ); + } + + const paths = yield* fs.readDirectory(nativeLogRoot(root)); + expect(paths.toSorted()).toEqual(["auth.jsonl", "auth.jsonl.1"]); + const records = []; + for (const path of paths) { + const contents = yield* fs.readFileString(join(nativeLogRoot(root), path)); + expect(new TextEncoder().encode(contents).byteLength).toBeLessThanOrEqual(64 * 1024); + expect(contents.split("\n").filter(Boolean).length).toBeLessThanOrEqual(1_000); + records.push( + ...contents + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)), + ); + } + expect(records.filter((record) => record.service === "auth")).toHaveLength(4); + for (let index = 0; index < 4; index += 1) { + expect(records.filter((record) => record.message === `${index}:${message}`)).toHaveLength( + 1, + ); + } + yield* Scope.close(writerScope, Exit.void); + const activePath = nativeServiceLogPath(root, "auth"); + const beforeCloseAppend = yield* fs.readFileString(activePath); + yield* logBuffer.append("auth", "stdout", "after-close"); + expect(yield* fs.readFileString(activePath)).toBe(beforeCloseAppend); + yield* fs.rename(activePath, `${activePath}.closed`); + }), + ).pipe(Effect.provide(NodeFileSystem.layer)), + ); + + it.live("rotates active journals on restart and bounds oversized records", () => + Effect.scoped( + Effect.gen(function* () { + const signal = yield* PubSub.unbounded(); + const fs = yield* FileSystem.FileSystem.pipe( + Effect.provide(journalFileSystemLayer(signal)), + ); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-logs-" }); + const logRoot = nativeLogRoot(root); + yield* fs.makeDirectory(logRoot, { recursive: true }); + const authPath = nativeServiceLogPath(root, "auth"); + const seededLines = Array.from({ length: 850 }, (_, index) => + JSON.stringify({ + timestamp: index, + service: "auth", + stream: "stdout", + message: `seed-${index}`, + }), + ).join("\n"); + const seeded = `${seededLines}\n`; + yield* fs.writeFileString(authPath, seeded); + + const writerScope = yield* Scope.make("sequential"); + const services = yield* Layer.build(LogBuffer.layer); + const logBuffer = Context.get(services, LogBuffer); + yield* startNativeLogWriter(logBuffer, root).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Scope.Scope, writerScope), + ); + yield* awaitDirectoryState(fs, logRoot, { + ["auth.jsonl.1"]: seeded, + }); + + yield* awaitDirectoryState( + fs, + logRoot, + { + ["auth.jsonl"]: (content) => content.includes('"message":"after-restart-150"'), + ["auth.jsonl.1"]: seeded, + ["postgres.jsonl"]: (content) => content.includes('"service":"postgres"'), + }, + Effect.gen(function* () { + for (let index = 0; index < 151; index += 1) { + yield* logBuffer.append("auth", "stdout", `after-restart-${index}`); + } + yield* logBuffer.append("postgres", "stderr", "😀".repeat(100_000)); + }), + signal, + ); + + const rotated = yield* fs.readFileString(join(logRoot, "auth.jsonl.1")); + expect(rotated.split("\n").filter(Boolean)).toHaveLength(850); + expect(new TextEncoder().encode(rotated).byteLength).toBeLessThanOrEqual(64 * 1024); + const active = yield* fs.readFileString(authPath); + expect(active.split("\n").filter(Boolean)).toHaveLength(151); + expect( + active + .split("\n") + .filter(Boolean) + .every((line) => line.includes('"service":"auth"')), + ).toBe(true); + const oversized = yield* fs.readFileString(join(logRoot, "postgres.jsonl")); + expect(new TextEncoder().encode(oversized).byteLength).toBeLessThanOrEqual(64 * 1024); + const oversizedEvent = JSON.parse(oversized.trim()); + expect(oversizedEvent).toMatchObject({ service: "postgres", stream: "stderr" }); + expect(oversizedEvent.message).not.toHaveLength(100_000); + + yield* Scope.close(writerScope, Exit.void); + }), + ).pipe(Effect.provide(NodeFileSystem.layer)), + ); + + it.live("keeps concurrent real buffers isolated to their distinct runtime roots", () => + Effect.scoped( + Effect.gen(function* () { + const signal = yield* PubSub.unbounded(); + const fs = yield* FileSystem.FileSystem.pipe( + Effect.provide(journalFileSystemLayer(signal)), + ); + const firstRoot = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-logs-a-" }); + const secondRoot = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-logs-b-" }); + const firstServices = yield* Layer.build(LogBuffer.layer); + const secondServices = yield* Layer.build(LogBuffer.layer); + const firstBuffer = Context.get(firstServices, LogBuffer); + const secondBuffer = Context.get(secondServices, LogBuffer); + yield* startNativeLogWriter(firstBuffer, firstRoot).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ); + yield* startNativeLogWriter(secondBuffer, secondRoot).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ); + + const firstReady = yield* Deferred.make(); + const secondReady = yield* Deferred.make(); + const firstWait = yield* Effect.forkChild( + awaitDirectoryState( + fs, + nativeLogRoot(firstRoot), + { + ["auth.jsonl"]: (content) => content.includes('"message":"first-stack"'), + }, + Effect.void, + signal, + firstReady, + ), + { startImmediately: true }, + ); + const secondWait = yield* Effect.forkChild( + awaitDirectoryState( + fs, + nativeLogRoot(secondRoot), + { + ["auth.jsonl"]: (content) => content.includes('"message":"second-stack"'), + }, + Effect.void, + signal, + secondReady, + ), + { startImmediately: true }, + ); + yield* Deferred.await(firstReady); + yield* Deferred.await(secondReady); + yield* firstBuffer.append("auth", "stdout", "first-stack"); + yield* secondBuffer.append("auth", "stdout", "second-stack"); + yield* Fiber.join(firstWait); + yield* Fiber.join(secondWait); + + expect(yield* fs.readFileString(nativeServiceLogPath(firstRoot, "auth"))).not.toContain( + "second-stack", + ); + expect(yield* fs.readFileString(nativeServiceLogPath(secondRoot, "auth"))).not.toContain( + "first-stack", + ); + }), + ).pipe(Effect.provide(NodeFileSystem.layer)), + ); +}); diff --git a/packages/stack/src/NativeLogWriter.ts b/packages/stack/src/NativeLogWriter.ts new file mode 100644 index 0000000000..a0a9096f9b --- /dev/null +++ b/packages/stack/src/NativeLogWriter.ts @@ -0,0 +1,473 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- Native log paths are synchronous platform-boundary values. +import { join } from "node:path"; +import { + Deferred, + Duration, + Effect, + Exit, + FileSystem, + Fiber, + Pull, + Schedule, + Scope, + Stream, +} from "effect"; +import { LogBuffer, type LogEntry } from "@supabase/process-compose"; +import { StackBuildError } from "./errors.ts"; +import { prepareNativeDirectory, writeNativeFile } from "./native-filesystem.ts"; +import type { ServiceName } from "./versions.ts"; + +/** Native journals live below the runtime root and are private to one stack. */ +export const nativeLogRoot = (runtimeRoot: string): string => join(runtimeRoot, "logs"); + +/** Encode arbitrary service labels as one reversible path component. */ +const serviceFileName = (service: string): string => encodeURIComponent(service); + +/** The active JSONL journal for one native service. Rotated segments append `.1`, `.2`, ... . */ +export const nativeServiceLogPath = (runtimeRoot: string, service: ServiceName): string => + join(nativeLogRoot(runtimeRoot), `${serviceFileName(service)}.jsonl`); + +/** + * Keep each segment bounded while retaining a small, deterministic history per + * service. An oversized incoming record is truncated at a Unicode code-point + * boundary; oversized lines already present at attachment are discarded while + * the active segment is normalized and rotated. + */ +const NATIVE_LOG_SEGMENT_BYTES = 64 * 1024; +const NATIVE_LOG_SEGMENT_RECORDS = 1_000; +/** Number of JSONL files retained for each service (active + rotated segments). */ +export const NATIVE_LOG_SEGMENTS = 3; +/** Retry transient journal failures with interruptible exponential backoff. */ +const nativeLogWriteRetry = Schedule.exponential(Duration.millis(50)).pipe( + Schedule.jittered, + Schedule.upTo({ times: 3 }), +); + +const textEncoder = new TextEncoder(); + +interface Segment { + readonly service: string; + readonly path: string; + readonly scope: Scope.Closeable; + readonly file: FileSystem.File; + bytes: number; + records: number; +} + +export interface NativeLogWriter { + readonly shutdown: Effect.Effect; +} + +const pathForService = (runtimeRoot: string, service: string, index = 0): string => { + const active = join(nativeLogRoot(runtimeRoot), `${serviceFileName(service)}.jsonl`); + return index === 0 ? active : `${active}.${index}`; +}; + +/** Paths for the active journal and every rotated segment of one service. */ +export const nativeServiceLogSegmentPaths = ( + runtimeRoot: string, + service: ServiceName, +): ReadonlyArray => + Array.from({ length: NATIVE_LOG_SEGMENTS }, (_, index) => + pathForService(runtimeRoot, service, index), + ); + +const encodeEntry = (entry: LogEntry): Uint8Array => { + const encodeMessage = (message: string): Uint8Array => + textEncoder.encode( + `${JSON.stringify({ + timestamp: entry.timestamp, + service: entry.service, + stream: entry.stream, + message, + })}\n`, + ); + const full = encodeMessage(entry.line); + if (full.byteLength <= NATIVE_LOG_SEGMENT_BYTES) return full; + + // Truncate only at code-point boundaries, then re-encode to account for JSON + // escaping. This keeps every journal record valid UTF-8 and below the + // documented segment bound without splitting a multibyte character. + const codePoints = Array.from(entry.line); + let low = 0; + let high = codePoints.length; + let best = ""; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const candidate = codePoints.slice(0, middle).join(""); + if (encodeMessage(candidate).byteLength <= NATIVE_LOG_SEGMENT_BYTES) { + best = candidate; + low = middle + 1; + } else { + high = middle - 1; + } + } + return encodeMessage(best); +}; + +/** + * Starts one scoped subscriber for a stack's native LogBuffer. The subscriber + * is deliberately a sliding LogBuffer consumer: child-process appenders never + * wait for filesystem I/O, while this fiber serializes each service's journal. + */ +export const startNativeLogWriter = ( + logBuffer: LogBuffer["Service"], + runtimeRoot: string, +): Effect.Effect => + Effect.suspend(() => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parentScope = yield* Effect.scope; + const logRoot = nativeLogRoot(runtimeRoot); + yield* prepareNativeDirectory(logRoot, `Failed to create native log directory ${logRoot}`); + + // Existing journals have no trusted record metadata, so oversized lines + // are discarded rather than copied past the bound during normalization. + const normalizeExistingSegment = ( + path: string, + ): Effect.Effect => + Effect.gen(function* () { + const content = yield* fs.readFileString(path).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: `Failed to read native log segment ${path}`, + cause, + }), + ), + ); + const kept: Array = []; + let bytes = 0; + for (const line of content + .split("\n") + .filter((line) => line.length > 0) + .toReversed()) { + const encoded = textEncoder.encode(`${line}\n`); + if (encoded.byteLength > NATIVE_LOG_SEGMENT_BYTES) continue; + if (kept.length >= NATIVE_LOG_SEGMENT_RECORDS) break; + if (bytes + encoded.byteLength > NATIVE_LOG_SEGMENT_BYTES) break; + kept.push(`${line}\n`); + bytes += encoded.byteLength; + } + kept.reverse(); + const normalized = kept.join(""); + if (normalized !== content) { + yield* writeNativeFile(path, normalized, `Failed to bound native log segment ${path}`, { + flag: "w", + mode: 0o600, + }); + } + return normalized; + }); + + const rotateExistingActive = ( + activePath: string, + ): Effect.Effect => + Effect.gen(function* () { + const content = yield* normalizeExistingSegment(activePath); + if (content.length === 0) return; + for (let index = NATIVE_LOG_SEGMENTS - 1; index >= 2; index -= 1) { + const from = `${activePath}.${index - 1}`; + const to = `${activePath}.${index}`; + const exists = yield* fs.exists(from).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: `Failed to inspect native log segment ${from}`, + cause, + }), + ), + ); + if (exists) { + yield* fs.rename(from, to).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: `Failed to rotate native log segment ${from}`, + cause, + }), + ), + ); + } + } + yield* fs.rename(activePath, `${activePath}.1`).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: `Failed to attach native log segment ${activePath}`, + cause, + }), + ), + ); + }); + + const existingEntries = yield* fs.readDirectory(logRoot).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: `Failed to inspect native log directory ${logRoot}`, + cause, + }), + ), + ); + for (const entry of existingEntries.filter((name) => name.endsWith(".jsonl"))) { + const activePath = join(logRoot, entry); + for (let index = 1; index < NATIVE_LOG_SEGMENTS; index += 1) { + const segmentPath = `${activePath}.${index}`; + const exists = yield* fs.exists(segmentPath).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: `Failed to inspect native log segment ${segmentPath}`, + cause, + }), + ), + ); + if (exists) yield* normalizeExistingSegment(segmentPath); + } + yield* rotateExistingActive(activePath); + } + + // Keep journal handles in a writer-owned scope. The parent scope receives a + // finalizer below, after this writer has been registered and before the + // orchestrator is built, so direct scope disposal drains after services + // have emitted their shutdown logs. + const writerScope = yield* Scope.make("sequential"); + const segments = new Map(); + const openSegment = (service: string): Effect.Effect => + Effect.gen(function* () { + const segmentScope = yield* Scope.fork(writerScope, "sequential"); + const path = pathForService(runtimeRoot, service); + const opened = yield* fs.open(path, { flag: "a", mode: 0o600 }).pipe( + Effect.provideService(Scope.Scope, segmentScope), + Effect.mapError( + (cause) => + new StackBuildError({ + detail: `Failed to open native service log ${path}`, + cause, + }), + ), + Effect.onError(() => Scope.close(segmentScope, Exit.void)), + ); + const info = yield* opened.stat.pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: `Failed to inspect native service log ${path}`, + cause, + }), + ), + Effect.onError(() => Scope.close(segmentScope, Exit.void)), + ); + return { + service, + path, + scope: segmentScope, + file: opened, + bytes: Number(info.size), + records: 0, + } satisfies Segment; + }); + + const rotate = (current: Segment): Effect.Effect => + Effect.gen(function* () { + yield* current.file.sync.pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: `Failed to flush native service log ${current.path}`, + cause, + }), + ), + ); + yield* Scope.close(current.scope, Exit.void); + // The handle is now closed. Remove it before any fallible rename or + // reopen so a retry can acquire a fresh segment instead of reusing + // stale ownership state. + segments.delete(current.service); + for (let index = NATIVE_LOG_SEGMENTS - 1; index >= 1; index -= 1) { + const from = pathForService(runtimeRoot, current.service, index - 1); + const to = pathForService(runtimeRoot, current.service, index); + const exists = yield* fs.exists(from).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: `Failed to inspect native log segment ${from}`, + cause, + }), + ), + ); + if (exists) { + yield* fs.rename(from, to).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: `Failed to rotate native log segment ${from}`, + cause, + }), + ), + ); + } + } + return yield* openSegment(current.service); + }); + + const writeEntry = (entry: LogEntry): Effect.Effect => + Effect.gen(function* () { + let segment = segments.get(entry.service); + if (segment === undefined) { + segment = yield* openSegment(entry.service); + segments.set(entry.service, segment); + } + const bytes = encodeEntry(entry); + if ( + segment.bytes > 0 && + (segment.bytes + bytes.byteLength > NATIVE_LOG_SEGMENT_BYTES || + segment.records >= NATIVE_LOG_SEGMENT_RECORDS) + ) { + segment = yield* rotate(segment); + segments.set(entry.service, segment); + } + yield* segment.file.writeAll(bytes).pipe( + Effect.mapError( + (cause) => + new StackBuildError({ + detail: `Failed to append native service log ${segment.path}`, + cause, + }), + ), + ); + segment.bytes += bytes.byteLength; + segment.records += 1; + }); + + const invalidateSegment = (service: string): Effect.Effect => + Effect.suspend(() => { + const segment = segments.get(service); + if (segment === undefined) return Effect.void; + segments.delete(service); + return Scope.close(segment.scope, Exit.void); + }); + + const failedServices = new Set(); + + const writeEntryWithRecovery = (entry: LogEntry): Effect.Effect => + Effect.suspend(() => { + const retry = !failedServices.has(entry.service); + const attempt = writeEntry(entry).pipe( + Effect.tapError(() => invalidateSegment(entry.service)), + ); + const write = retry ? attempt.pipe(Effect.retry(nativeLogWriteRetry)) : attempt; + return write.pipe( + Effect.tap(() => + Effect.sync(() => { + failedServices.delete(entry.service); + }), + ), + Effect.catchTag("StackBuildError", (error) => + Effect.suspend(() => { + const firstFailure = !failedServices.has(entry.service); + failedServices.add(entry.service); + return firstFailure + ? Effect.logError(`Native log write dropped ${entry.service}: ${error.detail}`) + : Effect.void; + }), + ), + ); + }); + + const subscribed = Deferred.makeUnsafe(); + const shutdownMarker = `native-log-writer:${runtimeRoot}`; + const worker = Effect.gen(function* () { + const pull = yield* Stream.toPull(logBuffer.subscribeAllInternal); + yield* Deferred.succeed(subscribed, undefined); + let running = true; + while (running) { + const chunk = yield* pull.pipe(Pull.catchDone(() => Effect.succeed(null))); + if (chunk === null) { + running = false; + } else { + const byService = new Map>(); + for (const event of chunk) { + if (event._tag === "Control" && event.id === shutdownMarker) { + running = false; + break; + } + if (event._tag !== "Entry") continue; + const entry = event.entry; + const entries = byService.get(entry.service); + if (entries === undefined) { + byService.set(entry.service, [entry]); + } else { + entries.push(entry); + } + } + yield* Effect.forEach( + byService.values(), + (entries) => Effect.forEach(entries, writeEntryWithRecovery, { discard: true }), + { discard: true, concurrency: "unbounded" }, + ); + } + } + }).pipe( + Effect.mapError((cause) => + cause instanceof StackBuildError + ? cause + : new StackBuildError({ detail: "Native log writer failed", cause }), + ), + Effect.tapError((error) => + Deferred.fail(subscribed, error).pipe( + Effect.ignore, + Effect.andThen(Effect.logError(`Native log writer stopped: ${error.detail}`)), + ), + ), + ); + + const workerFiber = yield* Effect.forkIn(worker, writerScope, { startImmediately: true }); + yield* Deferred.await(subscribed); + + const closeSegments = Effect.gen(function* () { + for (const segment of segments.values()) { + yield* Scope.close(segment.scope, Exit.void); + } + segments.clear(); + yield* Scope.close(writerScope, Exit.void); + }); + const shutdownResult = yield* Deferred.make>(); + const runExit = (exit: Exit.Exit) => + Exit.match(exit, { + onSuccess: (value) => Effect.succeed(value), + onFailure: (cause) => Effect.failCause(cause), + }); + let shutdownStarted = false; + const shutdown = Effect.suspend(() => { + if (shutdownStarted) { + return Deferred.await(shutdownResult).pipe(Effect.flatMap(runExit)); + } + shutdownStarted = true; + return Effect.exit( + logBuffer + .appendControl(shutdownMarker) + .pipe( + Effect.andThen( + Fiber.join(workerFiber).pipe( + Effect.catchTag("StackBuildError", (error) => + Effect.logError(`Native log writer stopped: ${error.detail}`).pipe( + Effect.asVoid, + ), + ), + ), + ), + Effect.andThen(closeSegments), + ), + ).pipe( + Effect.flatMap((exit) => + Deferred.succeed(shutdownResult, exit).pipe(Effect.andThen(runExit(exit))), + ), + ); + }).pipe(Effect.uninterruptible); + + yield* Scope.addFinalizer(parentScope, shutdown); + return { shutdown }; + }), + ); diff --git a/packages/stack/src/NativeServiceGraphLifecycle.integration.test.ts b/packages/stack/src/NativeServiceGraphLifecycle.integration.test.ts new file mode 100644 index 0000000000..6e147dfecb --- /dev/null +++ b/packages/stack/src/NativeServiceGraphLifecycle.integration.test.ts @@ -0,0 +1,625 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- Public Stack lifecycle scenarios use temporary native roots and Effect-backed process fixtures. + +import { describe, expect, it } from "@effect/vitest"; +import { NodeServices } from "@effect/platform-node"; +import { + Cause, + Context, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + Option, + Predicate, + Sink, + Stream, +} from "effect"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createServer } from "node:net"; +import { Stack } from "./Stack.ts"; +import { localStackLayer } from "./LocalStack.ts"; +import { StackBuilder } from "./StackBuilder.ts"; +import { StackPreparation } from "./StackPreparation.ts"; +import { resolveConfig } from "./StackConfigResolver.ts"; +import { SERVICE_NAMES } from "./ServiceCatalog.ts"; +import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; +import type { PortSet } from "./PortCatalog.ts"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { buildGraph, type ServiceDef } from "@supabase/process-compose"; +import { StackBuildError } from "./errors.ts"; +import type { PreparedStackArtifacts } from "./StackPreparation.ts"; +import type { ResolvedStackConfig } from "./StackConfig.ts"; +import type { PortLease } from "./PortAllocator.ts"; +import { reservePortSet } from "./PortAllocator.ts"; +import { systemError, type PlatformError } from "effect/PlatformError"; + +interface SpawnRecord { + readonly service: string; + readonly pid: number; + readonly args: ReadonlyArray; + readonly crash: Effect.Effect; +} + +const decodeSupervisor = ( + args: ReadonlyArray, +): { command: string; args: string[] } | null => { + const encoded = args.at(-1); + if (encoded === undefined) return null; + try { + const decoded: unknown = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); + if ( + typeof decoded !== "object" || + decoded === null || + !("command" in decoded) || + typeof decoded.command !== "string" || + !("args" in decoded) || + !Array.isArray(decoded.args) || + !decoded.args.every((arg) => typeof arg === "string") + ) { + return null; + } + return { command: decoded.command, args: decoded.args }; + } catch { + return null; + } +}; + +const serviceNameForSupervisor = (command: string, args: ReadonlyArray): string | null => { + if (command.includes("supabase-postgres-init.sh")) return "postgres"; + if (command === "bash" && args.some((arg) => arg.includes("supabase_migrations"))) { + return "postgres-init"; + } + const service = SERVICE_NAMES.find((candidate) => command.includes(`/${candidate}/`)); + if (service !== undefined) { + if (service === "realtime" && command.endsWith("/migrate")) return "realtime-migrate"; + if (service === "realtime" && args[0] === "eval") return "realtime-seed"; + if (service === "analytics" && args[0] === "eval") { + return args[1]?.includes("Logflare.SingleTenant.create_default_plan") + ? "analytics-seed" + : "analytics-migrate"; + } + if (service === "pooler" && command.endsWith("/migrate")) return "pooler-migrate"; + if (service === "pooler" && command.endsWith("/supavisor")) return "pooler-bootstrap"; + return service; + } + return null; +}; + +const oneShotServices = new Set([ + "postgres-init", + "realtime-migrate", + "realtime-seed", + "analytics-migrate", + "analytics-seed", + "pooler-migrate", + "pooler-bootstrap", +]); + +const nativeLifecycleError = (method: string, port: number, cause: unknown): PlatformError => + systemError({ + _tag: "Unknown", + module: "native-service-lifecycle-test", + method, + pathOrDescriptor: port, + cause, + }); + +const listenTestServer = (server: ReturnType, port: number) => + Effect.callback((resume, signal) => { + let completed = false; + const cleanup = () => { + server.off("error", onError); + server.off("listening", onListening); + signal.removeEventListener("abort", onAbort); + }; + const complete = (effect: Effect.Effect) => { + if (completed) return; + completed = true; + cleanup(); + resume(effect); + }; + const onError = (cause: unknown) => + complete(Effect.fail(nativeLifecycleError("listen", port, cause))); + const onListening = () => complete(Effect.void); + const onAbort = () => { + if (completed) return; + completed = true; + cleanup(); + }; + + server.once("error", onError); + server.once("listening", onListening); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) { + onAbort(); + return Effect.sync(() => { + cleanup(); + if (server.listening) server.close(); + }); + } + try { + server.listen({ port, host: "127.0.0.1", signal }); + } catch (cause) { + onError(cause); + } + return Effect.sync(() => { + cleanup(); + if (server.listening) server.close(); + }); + }); + +const closeTestServer = (server: ReturnType, port: number) => + Effect.callback((resume, signal) => { + let completed = false; + const cleanup = () => { + server.off("error", onError); + server.off("close", onClose); + signal.removeEventListener("abort", onAbort); + }; + const complete = (effect: Effect.Effect) => { + if (completed) return; + completed = true; + cleanup(); + resume(effect); + }; + const onError = (cause: unknown) => + complete(Effect.fail(nativeLifecycleError("close", port, cause))); + const onClose = () => complete(Effect.void); + const onAbort = () => { + if (completed) return; + completed = true; + cleanup(); + if (server.listening) server.close(); + }; + + server.once("error", onError); + server.once("close", onClose); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) { + onAbort(); + return Effect.void; + } + try { + server.close(); + } catch (cause) { + onError(cause); + } + return Effect.sync(() => { + cleanup(); + if (server.listening) server.close(); + }); + }); + +const controllableSpawner = (options: { readonly bindPoolerBootstrapPort?: number } = {}) => { + const spawned: SpawnRecord[] = []; + const killed: string[] = []; + let nextPid = 10_000; + + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = Predicate.isTagged(command, "StandardCommand") ? command.command : ""; + const args = Predicate.isTagged(command, "StandardCommand") ? command.args : []; + const supervisor = cmd === process.execPath ? decodeSupervisor(args) : null; + const service = + supervisor === null + ? null + : serviceNameForSupervisor(supervisor.command, supervisor.args); + if (options.bindPoolerBootstrapPort !== undefined && service === "pooler-bootstrap") { + const port = options.bindPoolerBootstrapPort; + const server = createServer(); + yield* listenTestServer(server, port); + yield* closeTestServer(server, port); + } + const exitDeferred = yield* Deferred.make(); + let running = true; + const pid = nextPid++; + const finish = (code: number) => + Effect.sync(() => { + running = false; + }).pipe( + Effect.andThen(Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(code))), + Effect.ignore, + ); + + if (cmd === "true" || (service !== null && oneShotServices.has(service))) { + yield* finish(0); + } + + if (service !== null) { + const record: SpawnRecord = { + service, + pid, + args, + crash: finish(1), + }; + spawned.push(record); + } + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(pid), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.sync(() => running), + stdin: Sink.drain, + kill: (opts) => { + if (service !== null) killed.push(service); + return finish(opts?.killSignal === "SIGKILL" ? 137 : 143); + }, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ); + return { + layer, + spawned, + killed, + crash: (service: string) => { + const record = [...spawned].reverse().find((candidate) => candidate.service === service); + return record === undefined ? Effect.die(`No running process for ${service}`) : record.crash; + }, + }; +}; + +const latestPid = ( + spawner: ReturnType, + service: string, +): number | null => + [...spawner.spawned].reverse().find((entry) => entry.service === service)?.pid ?? null; + +const ports = (base: number): PortSet => ({ + apiPort: base, + dbPort: base + 1, + authPort: base + 2, + postgrestPort: base + 3, + postgrestAdminPort: base + 4, + edgeRuntimePort: base + 5, + edgeRuntimeInspectorPort: base + 6, + realtimePort: base + 7, + storagePort: base + 8, + imgproxyPort: base + 9, + mailpitPort: base + 10, + mailpitSmtpPort: base + 11, + mailpitPop3Port: base + 12, + pgmetaPort: base + 13, + studioPort: base + 14, + analyticsPort: base + 15, + vectorAdminPort: base + 16, + poolerSessionPort: base + 17, + poolerTransactionPort: base + 18, + poolerApiPort: base + 19, + poolerInternalPort: base + 20, +}); + +const allBinaries = Object.fromEntries( + SERVICE_NAMES.map((service) => [service, `/fake/slim/${service}`]), +); + +const makeConfig = (root: string, base: number) => + resolveConfig( + { + mode: "native", + readiness: { mode: "finite", timeoutMs: 5_000 }, + servicePolicies: { + postgres: "eager", + postgrest: "lazy", + auth: "lazy", + "edge-runtime": "lazy", + realtime: "eager", + storage: "lazy", + imgproxy: "lazy", + mailpit: "eager", + pgmeta: "eager", + studio: "eager", + analytics: "eager", + vector: "eager", + pooler: "eager", + }, + edgeRuntime: {}, + realtime: {}, + storage: {}, + imgproxy: {}, + mailpit: {}, + pgmeta: {}, + studio: {}, + analytics: {}, + vector: {}, + pooler: {}, + }, + { + ports: ports(base), + stackRoot: join(root, "stack"), + runtimeRoot: join(root, "runtime"), + runtime: { mode: "native", containerRuntime: null }, + }, + ); + +const graphWithExecHealth = Layer.effect( + StackBuilder, + Effect.gen(function* () { + const builder = yield* StackBuilder; + return { + build: (config: ResolvedStackConfig, prepared: PreparedStackArtifacts) => + builder.build(config, prepared).pipe( + Effect.flatMap((result) => + buildGraph( + result.graph.startOrder.map((definition: ServiceDef) => + definition.healthCheck === undefined + ? definition + : { + ...definition, + healthCheck: { + ...definition.healthCheck, + probe: { _tag: "Exec", command: "true", args: [] }, + initialDelaySeconds: 0, + periodSeconds: 1, + timeoutSeconds: 1, + }, + }, + ), + ).pipe( + Effect.map((graph) => ({ ...result, graph })), + Effect.mapError( + (cause) => + new StackBuildError({ + detail: `Test graph health override failed: ${String(cause)}`, + }), + ), + ), + ), + ), + }; + }), +).pipe(Layer.provide(StackBuilder.layer)); + +const setup = ( + config: ResolvedStackConfig, + spawner = controllableSpawner(), + resolver = mockBinaryResolver({ binaries: allBinaries }), + portLease: PortLease = { + ports: config.ports, + reserve: () => Effect.void, + release: () => Effect.void, + releaseAll: Effect.void, + }, +) => { + const layer = localStackLayer(config, { + ports: portLease.ports, + reserve: portLease.reserve, + release: portLease.release, + releaseAll: portLease.releaseAll, + }).pipe( + Layer.provide(graphWithExecHealth), + Layer.provide( + StackPreparation.layer.pipe(Layer.provide(resolver.layer), Layer.provide(spawner.layer)), + ), + Layer.provide(spawner.layer), + Layer.provide(NodeServices.layer), + ); + return { layer, resolver, spawner }; +}; + +describe("native service graph lifecycle", () => { + it.live( + "starts mixed eager services, retries lazy Storage JIT, and restarts its owner closure", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + directory: tmpdir(), + prefix: "supabase-native-lifecycle-", + }); + const config = yield* makeConfig(root, 45_000).pipe(Effect.provide(NodeServices.layer)); + const resolver = mockBinaryResolver({ + binaries: allBinaries, + failOnceServices: ["storage"], + }); + const spawner = controllableSpawner(); + const { layer } = setup(config, spawner, resolver); + yield* Effect.gen(function* () { + const stack = yield* Stack.pipe(Effect.provide(layer)); + yield* stack.start; + for (const service of [ + "postgres", + "realtime", + "mailpit", + "pgmeta", + "studio", + "analytics", + "vector", + "pooler", + ]) { + expect((yield* stack.getState(service)).status).toBe("Healthy"); + } + expect((yield* stack.getState("storage")).status).toBe("Dormant"); + expect((yield* stack.getState("imgproxy")).status).toBe("Dormant"); + expect(resolver.resolved.some(({ service }) => service === "storage")).toBe(false); + expect(spawner.spawned.some(({ service }) => service === "storage")).toBe(false); + + const eagerSpawnCounts = new Map( + spawner.spawned.map(({ service }) => [ + service, + spawner.spawned.filter((entry) => entry.service === service).length, + ]), + ); + const eagerPids = new Map( + [...eagerSpawnCounts.keys()].map((service) => [service, latestPid(spawner, service)]), + ); + const firstActivation = yield* stack.startService("storage").pipe(Effect.exit); + expect(Exit.isFailure(firstActivation)).toBe(true); + if (Exit.isFailure(firstActivation)) { + expect(Cause.squash(firstActivation.cause)).toBeInstanceOf(StackBuildError); + } + expect((yield* stack.getState("storage")).status).toBe("Dormant"); + expect((yield* stack.getState("imgproxy")).status).toBe("Dormant"); + expect((yield* stack.getState("postgres")).status).toBe("Healthy"); + + yield* stack.startService("storage"); + expect((yield* stack.getState("storage")).status).toBe("Healthy"); + expect((yield* stack.getState("imgproxy")).status).toBe("Healthy"); + expect(resolver.resolved.some(({ service }) => service === "storage")).toBe(true); + expect(resolver.resolved.some(({ service }) => service === "imgproxy")).toBe(true); + expect((yield* stack.getState("edge-runtime")).status).toBe("Dormant"); + + const resolvedBeforeRestart = resolver.resolved.length; + const beforeRestart = new Map( + spawner.spawned.map(({ service }) => [ + service, + spawner.spawned.filter((entry) => entry.service === service).length, + ]), + ); + yield* stack.restartService("storage"); + yield* stack.waitReady("imgproxy"); + expect((yield* stack.getState("storage")).status).toBe("Healthy"); + expect((yield* stack.getState("imgproxy")).status).toBe("Healthy"); + expect(spawner.spawned.filter(({ service }) => service === "storage").length).toBe( + (beforeRestart.get("storage") ?? 0) + 1, + ); + expect(spawner.spawned.filter(({ service }) => service === "imgproxy").length).toBe( + (beforeRestart.get("imgproxy") ?? 0) + 1, + ); + expect(resolver.resolved.length).toBe(resolvedBeforeRestart); + for (const [service, count] of eagerSpawnCounts) { + if (service !== "storage" && service !== "imgproxy") { + expect(spawner.spawned.filter((entry) => entry.service === service).length).toBe( + count, + ); + expect(latestPid(spawner, service)).toBe(eagerPids.get(service)); + } + } + yield* stack.dispose; + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.live("starts Pooler when its bootstrap binds the allocated private shard span", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + directory: tmpdir(), + prefix: "supabase-native-pooler-bootstrap-", + }); + const lease = yield* reservePortSet([ + { field: "poolerInternalPort", selection: { kind: "automatic" } }, + ]); + const internalPort = lease.ports.poolerInternalPort; + if (internalPort === undefined) { + yield* lease.releaseAll; + return yield* Effect.die(new Error("Expected Pooler internal port allocation")); + } + yield* Effect.gen(function* () { + const config = yield* makeConfig(root, internalPort - 20); + const spawner = controllableSpawner({ bindPoolerBootstrapPort: internalPort }); + const internalLease: PortLease = { + ports: config.ports, + reserve: (fields) => + fields.includes("poolerInternalPort") + ? lease.reserve(["poolerInternalPort"]) + : Effect.void, + release: (fields) => + fields.includes("poolerInternalPort") + ? lease.release(["poolerInternalPort"]) + : Effect.void, + releaseAll: lease.releaseAll, + }; + const { layer } = setup(config, spawner, undefined, internalLease); + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start; + expect((yield* stack.getState("pooler")).status).toBe("Healthy"); + yield* stack.dispose; + }).pipe(Effect.provide(layer)); + }).pipe(Effect.ensuring(lease.releaseAll)); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.live("recovers one crashed Storage stack without affecting a sibling stack", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const rootA = yield* fs.makeTempDirectoryScoped({ + directory: tmpdir(), + prefix: "supabase-native-lifecycle-a-", + }); + const rootB = yield* fs.makeTempDirectoryScoped({ + directory: tmpdir(), + prefix: "supabase-native-lifecycle-b-", + }); + const configA = yield* makeConfig(rootA, 45_100).pipe(Effect.provide(NodeServices.layer)); + const configB = yield* makeConfig(rootB, 45_200).pipe(Effect.provide(NodeServices.layer)); + const spawnerA = controllableSpawner(); + const spawnerB = controllableSpawner(); + const setupA = setup(configA, spawnerA); + const setupB = setup(configB, spawnerB); + const scope = yield* Effect.scope; + const contextA = yield* Layer.buildWithScope(setupA.layer, scope); + const contextB = yield* Layer.buildWithScope(setupB.layer, scope); + const stackA = Context.get(contextA, Stack); + const stackB = Context.get(contextB, Stack); + yield* stackA.start; + yield* stackB.start; + yield* stackA.startService("storage"); + yield* stackB.startService("storage"); + const stackACounts = new Map( + spawnerA.spawned.map(({ service }) => [ + service, + spawnerA.spawned.filter((entry) => entry.service === service).length, + ]), + ); + const stackAPids = new Map( + [...stackACounts.keys()].map((service) => [service, latestPid(spawnerA, service)]), + ); + const siblingCounts = new Map( + spawnerB.spawned.map(({ service }) => [ + service, + spawnerB.spawned.filter((entry) => entry.service === service).length, + ]), + ); + const previousStorage = yield* stackA.getState("storage"); + const recovered = yield* stackA.stateChanges("storage").pipe( + Effect.flatMap((changes) => + changes.pipe( + Stream.filter( + (state) => + state.status === "Healthy" && state.restartCount > previousStorage.restartCount, + ), + Stream.runHead, + ), + ), + Effect.forkChild({ startImmediately: true }), + ); + yield* spawnerA.crash("storage"); + const recoveredState = yield* Fiber.join(recovered).pipe( + Effect.map(Option.getOrThrow), + Effect.timeout("5 seconds"), + ); + expect(recoveredState.status).toBe("Healthy"); + expect(recoveredState.restartCount).toBeGreaterThan(previousStorage.restartCount); + expect((yield* stackA.getState("imgproxy")).status).toBe("Healthy"); + expect((yield* stackB.getState("storage")).status).toBe("Healthy"); + for (const [service, count] of stackACounts) { + if (service !== "storage") { + expect(spawnerA.spawned.filter((entry) => entry.service === service).length).toBe(count); + expect(latestPid(spawnerA, service)).toBe(stackAPids.get(service)); + } + } + for (const [service, count] of siblingCounts) { + expect(spawnerB.spawned.filter((entry) => entry.service === service).length).toBe(count); + } + const killedBeforeDispose = [...spawnerB.killed]; + yield* stackA.dispose; + expect(spawnerA.killed.length).toBeGreaterThan(0); + expect(spawnerB.killed).toEqual(killedBeforeDispose); + expect((yield* stackB.getState("storage")).status).toBe("Healthy"); + yield* stackB.dispose; + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); diff --git a/packages/stack/src/PortAllocator.integration.test.ts b/packages/stack/src/PortAllocator.integration.test.ts index 733917ed14..2d07e51be6 100644 --- a/packages/stack/src/PortAllocator.integration.test.ts +++ b/packages/stack/src/PortAllocator.integration.test.ts @@ -7,7 +7,11 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { NodeFileSystem } from "@effect/platform-node"; import { Cause, Effect, Exit, FileSystem } from "effect"; -import { reservePortSet, type PortReservationRequest } from "./PortAllocator.ts"; +import { + PortAllocationError, + reservePortSet, + type PortReservationRequest, +} from "./PortAllocator.ts"; const PORT_LEASE_CHILD = resolve(import.meta.dirname, "../tests/helpers/port-lease-child.ts"); const STACK_PACKAGE_ROOT = resolve(import.meta.dirname, ".."); @@ -145,6 +149,78 @@ const startChildLease = () => { return { child, ready }; }; +const MAX_PG_META_COLLISION_ATTEMPTS = 32; + +type PgMetaCollisionAttempt = + | { readonly kind: "retry"; readonly detail: string } + | { + readonly kind: "collision"; + readonly adminPort: number; + readonly basePort: number; + readonly error: PortAllocationError; + } + | { + readonly kind: "unexpected"; + readonly adminPort: number; + readonly basePort: number; + readonly error: unknown; + } + | { + readonly kind: "unexpected-success"; + readonly adminPort: number; + readonly basePort: number; + }; + +/** + * Keep the candidate admin listener owned while reservePortSet probes the + * exact PgMeta span. A base-port collision is retried with a fresh candidate; + * the scoped listener is closed before the next candidate is selected. + */ +const probePgMetaAdminCollision = (): Effect.Effect< + PgMetaCollisionAttempt, + Error, + FileSystem.FileSystem +> => + Effect.scoped( + Effect.gen(function* () { + const occupied = yield* occupyFreePort(); + const adminPort = occupied.port; + if (adminPort < 2 || adminPort > 65_535) { + return { + kind: "retry", + detail: `Ephemeral admin candidate ${adminPort} cannot form a valid PgMeta span`, + } as const; + } + const basePort = adminPort - 1; + const exit = yield* reservePortSet([ + { field: "pgmetaPort", selection: { kind: "exact", port: basePort } }, + ]).pipe(Effect.exit); + if (Exit.isSuccess(exit)) { + yield* exit.value.releaseAll; + return { kind: "unexpected-success", adminPort, basePort } as const; + } + const error = Cause.squash(exit.cause); + if ( + error instanceof PortAllocationError && + error.field === "pgmetaPort" && + error.port === adminPort + ) { + return { kind: "collision", adminPort, basePort, error } as const; + } + if ( + error instanceof PortAllocationError && + error.field === "pgmetaPort" && + error.port === basePort + ) { + return { + kind: "retry", + detail: `PgMeta base ${basePort} was already unavailable`, + } as const; + } + return { kind: "unexpected", adminPort, basePort, error } as const; + }), + ); + describe("reservePortSet", () => { it.each(["node", "bun"] as const)( "closes a pending listener when allocation is interrupted under %s", @@ -186,6 +262,83 @@ describe("reservePortSet", () => { } }); + it("keeps native Pooler shard listener spans disjoint across stacks", async () => { + const first = await run(reservePortSet([automatic("poolerInternalPort")])); + const second = await run(reservePortSet([automatic("poolerInternalPort")])); + try { + const firstBase = first.ports.poolerInternalPort; + const secondBase = second.ports.poolerInternalPort; + if (firstBase === undefined || secondBase === undefined) { + throw new Error("Expected native Pooler internal port allocations"); + } + const firstSpan = new Set(Array.from({ length: 8 }, (_, index) => firstBase + index)); + const secondSpan = new Set(Array.from({ length: 8 }, (_, index) => secondBase + index)); + expect([...firstSpan].some((port) => secondSpan.has(port))).toBe(false); + } finally { + await run(first.releaseAll); + await run(second.releaseAll); + } + }); + + it("keeps the PgMeta admin port out of other automatic allocations", async () => { + const lease = await run(reservePortSet([automatic("pgmetaPort"), automatic("apiPort")])); + try { + const pgmetaPort = lease.ports.pgmetaPort; + const apiPort = lease.ports.apiPort; + if (pgmetaPort === undefined || apiPort === undefined) { + throw new Error("Expected PgMeta and API ports"); + } + expect(apiPort).not.toBe(pgmetaPort + 1); + } finally { + await run(lease.releaseAll); + } + }); + + it("rejects an exact PgMeta base when its admin port is already owned", async () => { + await run( + Effect.gen(function* () { + const retries: Array = []; + for (let attempt = 1; attempt <= MAX_PG_META_COLLISION_ATTEMPTS; attempt += 1) { + const outcome = yield* probePgMetaAdminCollision(); + if (outcome.kind === "retry") { + retries.push(outcome.detail); + continue; + } + if (outcome.kind === "collision") { + expect(outcome.error).toMatchObject({ + field: "pgmetaPort", + port: outcome.adminPort, + }); + return; + } + if (outcome.kind === "unexpected-success") { + throw new Error( + `PgMeta exact allocation unexpectedly succeeded for base ${outcome.basePort} while admin ${outcome.adminPort} was owned`, + ); + } + throw new Error( + `PgMeta exact allocation failed unexpectedly for base ${outcome.basePort} with ${String(outcome.error)}`, + ); + } + throw new Error( + `Unable to obtain a free PgMeta base after ${MAX_PG_META_COLLISION_ATTEMPTS} attempts: ${retries.join("; ")}`, + ); + }), + ); + }); + + it("releases every PgMeta listener when releasing its field", async () => { + const lease = await run(reservePortSet([automatic("pgmetaPort")])); + try { + await run(lease.release(["pgmetaPort"])); + // Reusing the same lease retains both ownership claims. Successful + // re-reservation therefore proves both TCP listeners were released. + await run(lease.reserve(["pgmetaPort"])); + } finally { + await run(lease.releaseAll); + } + }); + it("retains claims after TCP release until releaseAll", async () => { const lease = await run(reservePortSet([automatic("apiPort")])); const port = lease.ports.apiPort; diff --git a/packages/stack/src/PortAllocator.ts b/packages/stack/src/PortAllocator.ts index 09ea2ce5ee..27d6f866d4 100644 --- a/packages/stack/src/PortAllocator.ts +++ b/packages/stack/src/PortAllocator.ts @@ -15,7 +15,7 @@ import { Semaphore, } from "effect"; import { PlatformError } from "effect/PlatformError"; -import { PortSetSchema, type PortField, type PortSet } from "./PortCatalog.ts"; +import { PORT_CATALOG, PortSetSchema, type PortField, type PortSet } from "./PortCatalog.ts"; export class PortAllocationError extends Data.TaggedError("PortAllocationError")<{ readonly detail: string; @@ -74,12 +74,22 @@ interface BoundPort { readonly server: Server; } +interface PortAllocation { + readonly ports: ReadonlyArray; + readonly basePort: number; +} + interface PortClaim { readonly path: string; readonly port: number; readonly token: string; } +const portSpan = (field: PortField): number => PORT_CATALOG[field].span ?? 1; + +const portsForField = (field: PortField, basePort: number): ReadonlyArray => + Array.from({ length: portSpan(field) }, (_, offset) => basePort + offset); + interface ClaimRecord { readonly pid: number; readonly token: string; @@ -402,7 +412,7 @@ const uniquePortFields = (fields: ReadonlyArray): ReadonlyArray, + reservations: Map>, fields: ReadonlyArray, ): Effect.Effect => // Cleanup must finish the ownership handoff even if the caller is @@ -411,9 +421,9 @@ const releaseReservations = ( Effect.forEach( uniquePortFields(fields), (field) => { - const server = reservations.get(field); - if (server === undefined) return Effect.void; - return closeServer(server).pipe( + const servers = reservations.get(field); + if (servers === undefined) return Effect.void; + return Effect.forEach(servers, closeServer, { discard: true }).pipe( Effect.tap(() => Effect.sync(() => reservations.delete(field))), ); }, @@ -422,7 +432,7 @@ const releaseReservations = ( ); const releaseClaims = ( - claims: Map, + claims: Map>, fields: ReadonlyArray, fs: FileSystem.FileSystem, ): Effect.Effect => @@ -430,11 +440,11 @@ const releaseClaims = ( Effect.forEach( uniquePortFields(fields), (field) => { - const claim = claims.get(field); - if (claim === undefined) return Effect.void; - return releasePortClaim(claim, fs).pipe( - Effect.tap(() => Effect.sync(() => claims.delete(field))), - ); + const fieldClaims = claims.get(field); + if (fieldClaims === undefined) return Effect.void; + return Effect.forEach(fieldClaims, (claim) => releasePortClaim(claim, fs), { + discard: true, + }).pipe(Effect.tap(() => Effect.sync(() => claims.delete(field)))); }, { discard: true }, ), @@ -442,37 +452,55 @@ const releaseClaims = ( const claimAndBind = ( field: PortField, - port: number, - claims: Map, + basePort: number, + claims: Map>, fs: FileSystem.FileSystem, root: string, -): Effect.Effect => { - const existingClaim = claims.get(field); - return Effect.gen(function* () { - const bound = yield* Effect.interruptible(bindPort(port)); - if (existingClaim !== undefined) { - claims.set(field, existingClaim); - return bound; - } +): Effect.Effect => + Effect.suspend(() => { + const bound: Array = []; + const acquiredClaims: Array = []; + const existingClaims = claims.get(field); + const cleanup = Effect.all([releaseBoundPorts(bound), releasePortClaims(acquiredClaims, fs)], { + discard: true, + }); - const claimExit = yield* Effect.exit( - Effect.interruptible( - acquirePortClaim(port, root).pipe(Effect.provideService(FileSystem.FileSystem, fs)), - ), - ); - if (Exit.isFailure(claimExit)) { - yield* closeServer(bound.server); - return yield* Effect.failCause(claimExit.cause); - } - yield* Effect.uninterruptible(Effect.sync(() => claims.set(field, claimExit.value))); - return bound; + return Effect.gen(function* () { + for (const port of portsForField(field, basePort)) { + bound.push(yield* Effect.interruptible(bindPort(port))); + } + + if (existingClaims === undefined) { + for (const port of portsForField(field, basePort)) { + const claim = yield* Effect.interruptible( + acquirePortClaim(port, root).pipe(Effect.provideService(FileSystem.FileSystem, fs)), + ); + acquiredClaims.push(claim); + } + yield* Effect.uninterruptible(Effect.sync(() => claims.set(field, acquiredClaims))); + } + + return { ports: bound, basePort }; + }).pipe(Effect.onError(() => cleanup)); }); -}; + +const releaseBoundPorts = (bound: ReadonlyArray): Effect.Effect => + Effect.uninterruptible( + Effect.forEach(bound, ({ server }) => closeServer(server), { discard: true }), + ); + +const releasePortClaims = ( + claims: ReadonlyArray, + fs: FileSystem.FileSystem, +): Effect.Effect => + Effect.uninterruptible( + Effect.forEach(claims, (claim) => releasePortClaim(claim, fs), { discard: true }), + ); const reserveReservations = ( ports: PortSet, - reservations: Map, - claims: Map, + reservations: Map>, + claims: Map>, fields: ReadonlyArray, fs: FileSystem.FileSystem, root: string, @@ -506,9 +534,12 @@ const reserveReservations = ( }) : withPortField(field, error), ), - Effect.tap(({ server }) => + Effect.tap(({ ports: boundPorts }) => Effect.sync(() => { - reservations.set(field, server); + reservations.set( + field, + boundPorts.map(({ server }) => server), + ); acquired.push(field); if (!existingClaim) acquiredClaims.push(field); }), @@ -529,8 +560,8 @@ const reserveReservations = ( const makePortLease = ( ports: PortSet, - reservations: Map, - claims: Map, + reservations: Map>, + claims: Map>, fs: FileSystem.FileSystem, root: string, ): PortLease => { @@ -557,12 +588,12 @@ const makePortLease = ( const reserveRandomPort = ( exclude: ReadonlySet, field: PortField, - claims: Map, + claims: Map>, fs: FileSystem.FileSystem, root: string, attempt = 0, ): Effect.Effect< - BoundPort, + PortAllocation, PortAllocationError | PortClaimCollisionError, FileSystem.FileSystem > => { @@ -574,40 +605,47 @@ const reserveRandomPort = ( }), ); } - return Effect.gen(function* () { - const bound = yield* Effect.interruptible(bindPort(0)); - if (exclude.has(bound.port)) { - yield* closeServer(bound.server); - return yield* reserveRandomPort(exclude, field, claims, fs, root, attempt + 1); - } + return Effect.suspend(() => { + const bound: Array = []; + const acquiredClaims: Array = []; + const cleanup = Effect.all([releaseBoundPorts(bound), releasePortClaims(acquiredClaims, fs)], { + discard: true, + }); - const claimExit = yield* Effect.exit( - Effect.interruptible( - acquirePortClaim(bound.port, root).pipe(Effect.provideService(FileSystem.FileSystem, fs)), - ), - ); - if (Exit.isSuccess(claimExit)) { - yield* Effect.uninterruptible(Effect.sync(() => claims.set(field, claimExit.value))); - return bound; - } + return Effect.gen(function* () { + const first = yield* Effect.interruptible(bindPort(0)); + bound.push(first); + const candidatePorts = portsForField(field, first.port); + if (candidatePorts.some((port) => port < 1 || port > 65_535 || exclude.has(port))) { + yield* cleanup; + return yield* reserveRandomPort(exclude, field, claims, fs, root, attempt + 1); + } + for (const port of candidatePorts.slice(1)) { + bound.push(yield* Effect.interruptible(bindPort(port))); + } - yield* closeServer(bound.server); - const failure = Cause.findErrorOption(claimExit.cause); - if (Option.isSome(failure) && failure.value instanceof PortClaimCollisionError) { - return yield* reserveRandomPort(exclude, field, claims, fs, root, attempt + 1); - } - if (Option.isSome(failure) && failure.value instanceof PlatformError) { - return yield* portAllocationFromCause(bound.port, failure.value); - } - if (Option.isSome(failure) && failure.value instanceof PortAllocationError) { - return yield* failure.value; - } - return yield* Effect.failCause( - Cause.map(claimExit.cause, (error) => - error instanceof PortClaimCollisionError || error instanceof PortAllocationError - ? error - : portAllocationFromCause(bound.port, error), + for (const port of candidatePorts) { + const claim = yield* Effect.interruptible( + acquirePortClaim(port, root).pipe(Effect.provideService(FileSystem.FileSystem, fs)), + ); + acquiredClaims.push(claim); + } + yield* Effect.uninterruptible(Effect.sync(() => claims.set(field, acquiredClaims))); + return { ports: bound, basePort: first.port }; + }).pipe( + Effect.catchTag("PortClaimCollisionError", () => { + return cleanup.pipe( + Effect.andThen(reserveRandomPort(exclude, field, claims, fs, root, attempt + 1)), + ); + }), + Effect.catchTag("PortAllocationError", (error) => + error.reason === "unavailable" + ? cleanup.pipe( + Effect.andThen(reserveRandomPort(exclude, field, claims, fs, root, attempt + 1)), + ) + : Effect.fail(error), ), + Effect.onError(() => cleanup), ); }); }; @@ -639,8 +677,8 @@ export const reservePortSet = ( options: PortSelectionOptions = {}, ): Effect.Effect => Effect.suspend(() => { - const reservations = new Map(); - const claims = new Map(); + const reservations = new Map>(); + const claims = new Map>(); const root = CLAIM_ROOT; const reserve = (fs: FileSystem.FileSystem) => Effect.gen(function* () { @@ -650,7 +688,7 @@ export const reservePortSet = ( const bindAndRegister = ( field: PortField, - acquisition: Effect.Effect, + acquisition: Effect.Effect, ) => Effect.uninterruptibleMask(() => Effect.gen(function* () { @@ -666,7 +704,10 @@ export const reservePortSet = ( : withPortField(field, error), ), ); - reservations.set(field, result.server); + reservations.set( + field, + result.ports.map(({ server }) => server), + ); return result; }), ); @@ -678,7 +719,7 @@ export const reservePortSet = ( ...allocated, ...(selection.kind === "automatic" ? (selection.excluded ?? []) : []), ]); - let bound: BoundPort; + let allocation: PortAllocation; if (selection.kind === "exact") { if ( @@ -692,24 +733,27 @@ export const reservePortSet = ( port: selection.port, }); } - if (exclude.has(selection.port)) { + const selectedPorts = portsForField(request.field, selection.port); + if (selectedPorts.some((port) => port < 1 || port > 65_535 || exclude.has(port))) { return yield* new PortAllocationError({ detail: `Port ${selection.port} is not available`, field: request.field, port: selection.port, }); } - bound = yield* bindAndRegister( + allocation = yield* bindAndRegister( request.field, claimAndBind(request.field, selection.port, claims, fs, root), ); } else if ( selection.preferred !== undefined && selection.preferred > 0 && - !exclude.has(selection.preferred) + portsForField(request.field, selection.preferred).every( + (port) => port <= 65_535 && !exclude.has(port), + ) ) { const preferred = selection.preferred; - bound = yield* bindAndRegister( + allocation = yield* bindAndRegister( request.field, claimAndBind(request.field, preferred, claims, fs, root).pipe( Effect.catchTag("PortClaimCollisionError", () => @@ -727,7 +771,7 @@ export const reservePortSet = ( ), ); } else { - bound = yield* bindAndRegister( + allocation = yield* bindAndRegister( request.field, reserveRandomPort(exclude, request.field, claims, fs, root).pipe( Effect.provideService(FileSystem.FileSystem, fs), @@ -735,8 +779,10 @@ export const reservePortSet = ( ); } - allocated.add(bound.port); - partial[request.field] = bound.port; + for (const port of portsForField(request.field, allocation.basePort)) { + allocated.add(port); + } + partial[request.field] = allocation.basePort; } const ports = yield* decodePortSet(partial); diff --git a/packages/stack/src/PortCatalog.ts b/packages/stack/src/PortCatalog.ts index fe879a414e..7eaf14e719 100644 --- a/packages/stack/src/PortCatalog.ts +++ b/packages/stack/src/PortCatalog.ts @@ -18,8 +18,13 @@ export interface AllocatedPorts { readonly pgmetaPort: number; readonly studioPort: number; readonly analyticsPort: number; - readonly poolerPort: number; + /** Native Vector's private administration/health listener. Docker keeps this internal. */ + readonly vectorAdminPort: number; + readonly poolerSessionPort: number; + readonly poolerTransactionPort: number; readonly poolerApiPort: number; + /** Native Supavisor's private session/transaction shard listener span. */ + readonly poolerInternalPort: number; } /** A selected allocation may contain any subset of the catalog fields. */ @@ -39,13 +44,19 @@ export type ConfigPortKey = | "local_smtp.pop3_port" | "studio.port" | "analytics.port" - | "db.pooler.port"; + | "db.pooler.session_port" + | "db.pooler.transaction_port" + | "db.pooler.api_port"; export interface PortCatalogEntry { readonly field: PortField; readonly configKey?: ConfigPortKey; readonly preferred?: number; readonly service?: ServiceName; + /** The field is leased only by native runtime processes. */ + readonly nativeOnly?: boolean; + /** Number of contiguous TCP ports owned by this field, including its base port. */ + readonly span?: number; readonly persistence: "runtime" | "sticky"; } @@ -104,7 +115,8 @@ const PORT_CATALOG_ENTRIES: { service: "mailpit", persistence: "sticky", }, - pgmetaPort: { field: "pgmetaPort", service: "pgmeta", persistence: "runtime" }, + // postgres-meta binds its administrative listener at PG_META_PORT + 1. + pgmetaPort: { field: "pgmetaPort", service: "pgmeta", span: 2, persistence: "runtime" }, studioPort: { field: "studioPort", configKey: "studio.port", @@ -119,14 +131,40 @@ const PORT_CATALOG_ENTRIES: { service: "analytics", persistence: "sticky", }, - poolerPort: { - field: "poolerPort", - configKey: "db.pooler.port", + vectorAdminPort: { + field: "vectorAdminPort", + service: "vector", + nativeOnly: true, + persistence: "runtime", + }, + poolerSessionPort: { + field: "poolerSessionPort", + configKey: "db.pooler.session_port", preferred: 54329, service: "pooler", persistence: "sticky", }, - poolerApiPort: { field: "poolerApiPort", service: "pooler", persistence: "runtime" }, + poolerTransactionPort: { + field: "poolerTransactionPort", + configKey: "db.pooler.transaction_port", + preferred: 54330, + service: "pooler", + persistence: "sticky", + }, + poolerApiPort: { + field: "poolerApiPort", + configKey: "db.pooler.api_port", + preferred: 54331, + service: "pooler", + persistence: "sticky", + }, + poolerInternalPort: { + field: "poolerInternalPort", + service: "pooler", + nativeOnly: true, + span: 8, + persistence: "runtime", + }, }; export const PORT_CATALOG = PORT_CATALOG_ENTRIES; @@ -147,8 +185,11 @@ export const PORT_FIELDS = [ "pgmetaPort", "studioPort", "analyticsPort", - "poolerPort", + "vectorAdminPort", + "poolerSessionPort", + "poolerTransactionPort", "poolerApiPort", + "poolerInternalPort", ] as const satisfies ReadonlyArray; export const stickyPortFields: ReadonlyArray = PORT_FIELDS.filter( (field) => PORT_CATALOG[field].persistence === "sticky", @@ -177,7 +218,9 @@ export const DEFAULT_PORTS: PortSet = { mailpitPop3Port: preferredPort("mailpitPop3Port"), studioPort: preferredPort("studioPort"), analyticsPort: preferredPort("analyticsPort"), - poolerPort: preferredPort("poolerPort"), + poolerSessionPort: preferredPort("poolerSessionPort"), + poolerTransactionPort: preferredPort("poolerTransactionPort"), + poolerApiPort: preferredPort("poolerApiPort"), }; export const AllocatedPortsSchema = Schema.Struct({ @@ -197,8 +240,11 @@ export const AllocatedPortsSchema = Schema.Struct({ pgmetaPort: Schema.Finite, studioPort: Schema.Finite, analyticsPort: Schema.Finite, - poolerPort: Schema.Finite, + vectorAdminPort: Schema.Finite, + poolerSessionPort: Schema.Finite, + poolerTransactionPort: Schema.Finite, poolerApiPort: Schema.Finite, + poolerInternalPort: Schema.Finite, }); export const PortSetSchema = Schema.Struct({ @@ -218,8 +264,11 @@ export const PortSetSchema = Schema.Struct({ pgmetaPort: Schema.optionalKey(Schema.Finite), studioPort: Schema.optionalKey(Schema.Finite), analyticsPort: Schema.optionalKey(Schema.Finite), - poolerPort: Schema.optionalKey(Schema.Finite), + vectorAdminPort: Schema.optionalKey(Schema.Finite), + poolerSessionPort: Schema.optionalKey(Schema.Finite), + poolerTransactionPort: Schema.optionalKey(Schema.Finite), poolerApiPort: Schema.optionalKey(Schema.Finite), + poolerInternalPort: Schema.optionalKey(Schema.Finite), }); export const ResolvedPortsSchema = Schema.Struct({ diff --git a/packages/stack/src/ServiceCatalog.ts b/packages/stack/src/ServiceCatalog.ts index fb2df56bfa..b347c0cd81 100644 --- a/packages/stack/src/ServiceCatalog.ts +++ b/packages/stack/src/ServiceCatalog.ts @@ -3,7 +3,6 @@ import { nativeTargetForPlatform, type NativeTarget, type PlatformInfo } from ". import type { PortField } from "./PortCatalog.ts"; import type { ServiceName } from "./ServiceName.ts"; -type ServiceRuntimeSupport = "native-preferred" | "docker-only"; type ArchiveFormat = "tar.zst"; export type ServicePreparationPolicy = "off" | "lazy" | "eager"; @@ -34,7 +33,12 @@ interface DockerImageSource { interface ServiceArtifactDefinition { readonly docker: DockerImageSource; - readonly native?: NativeReleaseSource; + readonly native: NativeReleaseSource; +} + +interface ServiceVersionDefaults { + readonly native: string; + readonly docker: string; } interface ServiceActivationPolicy { @@ -44,7 +48,7 @@ interface ServiceActivationPolicy { readonly owns: ReadonlyArray; } -export interface ServicePreparationMetadata { +interface ServicePreparationMetadata { /** Policies supported by the service's runtime/resource implementation. */ readonly supported: ReadonlyArray>; readonly default: Exclude; @@ -52,26 +56,28 @@ export interface ServicePreparationMetadata { readonly dependencies: ReadonlyArray; } -type ServiceConfigKey = - | "postgres" - | "postgrest" - | "auth" - | "edgeRuntime" - | "realtime" - | "storage" - | "imgproxy" - | "mailpit" - | "pgmeta" - | "studio" - | "analytics" - | "vector" - | "pooler"; +interface ServiceConfigKeyByService { + readonly postgres: "postgres"; + readonly postgrest: "postgrest"; + readonly auth: "auth"; + readonly "edge-runtime": "edgeRuntime"; + readonly realtime: "realtime"; + readonly storage: "storage"; + readonly imgproxy: "imgproxy"; + readonly mailpit: "mailpit"; + readonly pgmeta: "pgmeta"; + readonly studio: "studio"; + readonly analytics: "analytics"; + readonly vector: "vector"; + readonly pooler: "pooler"; +} + +export type ServiceConfigKey = ServiceConfigKeyByService[Name]; export interface ServiceCatalogEntry { readonly name: Name; - readonly configKey: ServiceConfigKey; - readonly defaultVersion: string; - readonly runtimeSupport: ServiceRuntimeSupport; + readonly configKey: ServiceConfigKey; + readonly defaultVersions: ServiceVersionDefaults; readonly artifact: ServiceArtifactDefinition; readonly activation: ServiceActivationPolicy; readonly preparation: ServicePreparationMetadata; @@ -110,6 +116,15 @@ const nativeRelease = ( }; }; +const nativeSource = ( + service: ServiceName, + requiredRuntimePaths: ReadonlyArray, +): NativeReleaseSource => ({ + provider: "github.com/supabase/slim-services", + resolve: (version, platform) => + nativeRelease(service, version, platform, { requiredRuntimePaths }), +}); + const preparation = ( supported: ReadonlyArray>, defaultPolicy: Exclude, @@ -124,29 +139,24 @@ const preparation = ( * Exhaustive static identity and capability metadata for public stack services. * Cross-service topology and process definitions deliberately remain in StackBuilder. */ -export const SERVICE_CATALOG = { +export const SERVICE_CATALOG: { + readonly [Name in ServiceName]: ServiceCatalogEntry; +} = { postgres: { name: "postgres", configKey: "postgres", - defaultVersion: "17.6.1.167", - runtimeSupport: "native-preferred", + defaultVersions: { native: "17.6.1.165", docker: "17.6.1.167" }, artifact: { docker: { repository: "postgres" }, - native: { - provider: "github.com/supabase/slim-services", - resolve: (version, platform) => - nativeRelease("postgres", version, platform, { - requiredRuntimePaths: [ - "bin/postgres", - "bin/pg_isready", - "bin/psql", - "share/supabase-cli/bin/supabase-postgres-init.sh", - "share/supabase-cli/config/pgsodium_getkey.sh", - "share/supabase-cli/migrations", - "lib", - ], - }), - }, + native: nativeSource("postgres", [ + "bin/postgres", + "bin/pg_isready", + "bin/psql", + "share/supabase-cli/bin/supabase-postgres-init.sh", + "share/supabase-cli/config/pgsodium_getkey.sh", + "share/supabase-cli/migrations", + "lib", + ]), }, activation: { activates: [], owns: [] }, preparation: preparation(["eager"], "eager"), @@ -155,17 +165,10 @@ export const SERVICE_CATALOG = { postgrest: { name: "postgrest", configKey: "postgrest", - defaultVersion: "v16.2", - runtimeSupport: "native-preferred", + defaultVersions: { native: "v16.2", docker: "v16.2" }, artifact: { docker: { repository: "postgrest" }, - native: { - provider: "github.com/supabase/slim-services", - resolve: (version, platform) => - nativeRelease("postgrest", version, platform, { - requiredRuntimePaths: ["bin/postgrest"], - }), - }, + native: nativeSource("postgrest", ["bin/postgrest"]), }, activation: { activates: [], owns: [] }, preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), @@ -174,17 +177,10 @@ export const SERVICE_CATALOG = { auth: { name: "auth", configKey: "auth", - defaultVersion: "v2.196.0", - runtimeSupport: "native-preferred", + defaultVersions: { native: "v2.196.0", docker: "v2.196.0" }, artifact: { docker: { repository: "auth" }, - native: { - provider: "github.com/supabase/slim-services", - resolve: (version, platform) => - nativeRelease("auth", version, platform, { - requiredRuntimePaths: ["bin/auth"], - }), - }, + native: nativeSource("auth", ["bin/auth"]), }, activation: { activates: [], owns: [] }, preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), @@ -193,10 +189,10 @@ export const SERVICE_CATALOG = { "edge-runtime": { name: "edge-runtime", configKey: "edgeRuntime", - defaultVersion: "v1.74.3", - runtimeSupport: "docker-only", + defaultVersions: { native: "v1.74.3", docker: "v1.74.3" }, artifact: { docker: { repository: "edge-runtime" }, + native: nativeSource("edge-runtime", ["bin/.edge-runtime-wrapped"]), }, activation: { activates: [], owns: [] }, preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), @@ -205,22 +201,22 @@ export const SERVICE_CATALOG = { realtime: { name: "realtime", configKey: "realtime", - defaultVersion: "v2.130.0", - runtimeSupport: "docker-only", + defaultVersions: { native: "v2.129.1", docker: "v2.130.0" }, artifact: { docker: { repository: "realtime" }, + native: nativeSource("realtime", ["bin/migrate", "bin/realtime", "bin/server"]), }, activation: { activates: [], owns: [] }, - preparation: preparation(["eager"], "eager", ["postgres"]), + preparation: preparation(["lazy", "eager"], "eager", ["postgres"]), portFields: ["realtimePort"], }, storage: { name: "storage", configKey: "storage", - defaultVersion: "v1.72.1", - runtimeSupport: "docker-only", + defaultVersions: { native: "v1.70.1", docker: "v1.72.1" }, artifact: { docker: { repository: "storage" }, + native: nativeSource("storage", ["bin/storage"]), }, activation: { activates: ["imgproxy"], owns: ["imgproxy"] }, preparation: preparation(["lazy", "eager"], "lazy", ["postgres", "imgproxy"]), @@ -229,10 +225,10 @@ export const SERVICE_CATALOG = { imgproxy: { name: "imgproxy", configKey: "imgproxy", - defaultVersion: "v3.8.0", - runtimeSupport: "docker-only", + defaultVersions: { native: "v3.8.0", docker: "v3.8.0" }, artifact: { docker: { repository: "imgproxy" }, + native: nativeSource("imgproxy", ["bin/imgproxy"]), }, activation: { activates: [], owns: [] }, preparation: preparation(["lazy", "eager"], "lazy", ["storage"]), @@ -241,10 +237,10 @@ export const SERVICE_CATALOG = { mailpit: { name: "mailpit", configKey: "mailpit", - defaultVersion: "v1.30.2", - runtimeSupport: "docker-only", + defaultVersions: { native: "v1.30.2", docker: "v1.30.2" }, artifact: { docker: { repository: "mailpit" }, + native: nativeSource("mailpit", ["bin/mailpit"]), }, activation: { activates: [], owns: [] }, preparation: preparation(["eager"], "eager"), @@ -253,10 +249,10 @@ export const SERVICE_CATALOG = { pgmeta: { name: "pgmeta", configKey: "pgmeta", - defaultVersion: "0.99.0", - runtimeSupport: "docker-only", + defaultVersions: { native: "v0.98.0", docker: "0.99.0" }, artifact: { docker: { repository: "pgmeta", tagPrefix: "v" }, + native: nativeSource("pgmeta", ["bin/pgmeta"]), }, activation: { activates: [], owns: [] }, preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), @@ -265,10 +261,13 @@ export const SERVICE_CATALOG = { studio: { name: "studio", configKey: "studio", - defaultVersion: "2026.08.24-sha-8ec45b2", - runtimeSupport: "docker-only", + defaultVersions: { + native: "2026.08.17-sha-0c1da8f", + docker: "2026.08.24-sha-8ec45b2", + }, artifact: { docker: { repository: "studio" }, + native: nativeSource("studio", ["bin/studio"]), }, activation: { activates: ["analytics"], owns: [] }, preparation: preparation(["eager"], "eager", ["pgmeta", "analytics"]), @@ -277,10 +276,10 @@ export const SERVICE_CATALOG = { analytics: { name: "analytics", configKey: "analytics", - defaultVersion: "v1.50.6", - runtimeSupport: "docker-only", + defaultVersions: { native: "v1.50.3", docker: "v1.50.6" }, artifact: { docker: { repository: "analytics" }, + native: nativeSource("analytics", ["bin/logflare"]), }, activation: { activates: ["vector"], owns: ["vector"] }, preparation: preparation(["lazy", "eager"], "lazy", ["postgres", "vector"]), @@ -289,48 +288,58 @@ export const SERVICE_CATALOG = { vector: { name: "vector", configKey: "vector", - defaultVersion: "0.53.0-alpine", - runtimeSupport: "docker-only", + defaultVersions: { native: "0.53.0", docker: "0.53.0-alpine" }, artifact: { docker: { registry: SUPABASE_GHCR_REGISTRY, repository: "vector" }, + native: nativeSource("vector", ["bin/vector"]), }, activation: { activates: [], owns: [] }, preparation: preparation(["lazy", "eager"], "lazy", ["analytics"]), - portFields: [], + // Vector's admin/health listener is private and only exists for native + // processes. ServicePorts filters this field out for Docker allocations. + portFields: ["vectorAdminPort"], }, pooler: { name: "pooler", configKey: "pooler", - defaultVersion: "2.9.12", - runtimeSupport: "docker-only", + defaultVersions: { native: "v2.9.10", docker: "2.9.12" }, artifact: { docker: { registry: SUPABASE_GHCR_REGISTRY, repository: "supavisor" }, + native: nativeSource("pooler", ["bin/migrate", "bin/supavisor", "bin/server"]), }, activation: { activates: [], owns: [] }, preparation: preparation(["eager"], "eager", ["postgres"]), - portFields: ["poolerPort", "poolerApiPort"], + portFields: [ + "poolerSessionPort", + "poolerTransactionPort", + "poolerApiPort", + "poolerInternalPort", + ], }, -} satisfies { readonly [Name in ServiceName]: ServiceCatalogEntry }; +}; export const SERVICE_NAMES: ReadonlyArray = Record.keys(SERVICE_CATALOG); export const DEFAULT_VERSIONS: Readonly> = Record.map( SERVICE_CATALOG, - (metadata) => metadata.defaultVersion, + (metadata) => metadata.defaultVersions.native, ); -export const serviceMetadata = (service: ServiceName): ServiceCatalogEntry => - SERVICE_CATALOG[service]; +export const DOCKER_DEFAULT_VERSIONS: Readonly> = Record.map( + SERVICE_CATALOG, + (metadata) => metadata.defaultVersions.docker, +); + +export const serviceMetadata = ( + service: Name, +): ServiceCatalogEntry => SERVICE_CATALOG[service]; export const nativeReleaseForService = ( service: ServiceName, version: string, platform: PlatformInfo, ): NativeReleaseArtifact | undefined => - serviceMetadata(service).artifact.native?.resolve(version, platform); - -export const isDockerOnlyService = (service: ServiceName): boolean => - SERVICE_CATALOG[service].runtimeSupport === "docker-only"; + serviceMetadata(service).artifact.native.resolve(version, platform); export const DEFAULT_SERVICE_POLICIES: Readonly< Record> diff --git a/packages/stack/src/ServicePorts.ts b/packages/stack/src/ServicePorts.ts index 5674f87ce9..d9edf87ac8 100644 --- a/packages/stack/src/ServicePorts.ts +++ b/packages/stack/src/ServicePorts.ts @@ -1,6 +1,6 @@ import { PORT_CATALOG, PORT_FIELDS, type PortField } from "./PortCatalog.ts"; import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; -import type { StackConfig } from "./StackConfig.ts"; +import type { StackConfig, StackMode } from "./StackConfig.ts"; const serviceEnabledForConfig = (config: StackConfig, service: keyof typeof SERVICE_CATALOG) => { if (config.servicePolicies?.[service] === "off") return false; @@ -8,12 +8,7 @@ const serviceEnabledForConfig = (config: StackConfig, service: keyof typeof SERV return config[service === "postgres" ? "postgres" : service] !== false; } if (service === "edge-runtime") { - const mode = config.mode ?? "native"; - return ( - !(mode === "native" && config.edgeRuntime === undefined) && - config.edgeRuntime !== false && - (config.edgeRuntime?.enabled ?? true) !== false - ); + return config.edgeRuntime !== false && (config.edgeRuntime?.enabled ?? true) !== false; } const configKey = SERVICE_CATALOG[service].configKey; if (configKey === "vector") return config.vector !== undefined && config.vector !== false; @@ -21,16 +16,22 @@ const serviceEnabledForConfig = (config: StackConfig, service: keyof typeof SERV }; /** Classifies active services before any ports are resolved. */ -export const portFieldsForConfigInput = (config: StackConfig = {}): ReadonlyArray => - PORT_FIELDS.filter( - (field) => - field === "apiPort" || - field === "dbPort" || - (PORT_CATALOG[field].service !== undefined && - serviceEnabledForConfig(config, PORT_CATALOG[field].service)), - ); +export const portFieldsForConfigInput = (config: StackConfig = {}): ReadonlyArray => { + const mode = config.mode ?? "native"; + return PORT_FIELDS.filter((field) => { + if (field === "apiPort" || field === "dbPort") return true; + const service = PORT_CATALOG[field].service; + if (service === undefined || !serviceEnabledForConfig(config, service)) return false; + // Native-only listeners (such as Vector's admin API) stay inside Docker + // containers and therefore must not consume a host lease there. + return !(PORT_CATALOG[field].nativeOnly === true && mode !== "native"); + }); +}; -export const portFieldsForService = (name: string): ReadonlyArray => { +export const portFieldsForService = (name: string, mode: StackMode): ReadonlyArray => { const service = SERVICE_NAMES.find((candidate) => candidate === name); - return service === undefined ? [] : SERVICE_CATALOG[service].portFields; + if (service === undefined) return []; + return SERVICE_CATALOG[service].portFields.filter( + (field) => !(PORT_CATALOG[field].nativeOnly === true && mode !== "native"), + ); }; diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index 3164d9c447..e9d62c316a 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -44,8 +44,11 @@ const defaultPorts: AllocatedPorts = { pgmetaPort: 54336, studioPort: 54337, analyticsPort: 54338, - poolerPort: 54339, - poolerApiPort: 54340, + vectorAdminPort: 54341, + poolerSessionPort: 54339, + poolerTransactionPort: 54340, + poolerApiPort: 54342, + poolerInternalPort: 54343, }; const defaultConfig: ResolvedStackConfig = { @@ -128,6 +131,24 @@ const edgeRuntimeConfig: ResolvedStackConfig = { }, }; +const poolerConfig: ResolvedStackConfig = { + ...defaultConfig, + servicePolicies: { ...defaultConfig.servicePolicies, pooler: "eager" }, + pooler: { + sessionPort: defaultPorts.poolerSessionPort, + transactionPort: defaultPorts.poolerTransactionPort, + apiPort: defaultPorts.poolerApiPort, + internalPort: defaultPorts.poolerInternalPort, + mode: "transaction", + version: DEFAULT_VERSIONS.pooler, + tenantId: "pooler tenant/@42", + encryptionKey: "12345678901234567890123456789012", + secretKeyBase: "1234567890123456789012345678901234567890123456789012345678901234", + defaultPoolSize: 20, + maxClientConn: 100, + }, +}; + const functionsBundle = (root: string, value: string): ResolvedFunctionsBundle => ({ env: { SHARED: value }, functions: [ @@ -199,7 +220,7 @@ describe("Stack", () => { ); it.effect("getInfo returns correct URLs based on config", () => { - const { layer } = setupLayer(); + const { layer } = setupLayer(poolerConfig); return Effect.gen(function* () { const stack = yield* Stack; @@ -209,6 +230,9 @@ describe("Stack", () => { expect(info.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); expect(info.serviceEndpoints.auth).toBe("http://127.0.0.1:54321/auth/v1"); expect(info.serviceEndpoints.postgrest).toBe("http://127.0.0.1:54321/rest/v1"); + expect(info.serviceEndpoints.pooler).toBe( + "postgresql://postgres.pooler%20tenant%2F%4042:postgres@127.0.0.1:54340/postgres", + ); }).pipe(Effect.provide(layer)); }); @@ -614,26 +638,6 @@ describe("Stack", () => { }).pipe(Effect.provide(layer), Effect.scoped, Effect.timeout("5 seconds")); }); - it.effect("rejects unsupported native services before resource planning", () => { - const config = { - ...edgeRuntimeConfig, - runtime: { mode: "native", containerRuntime: null }, - } satisfies ResolvedStackConfig; - const { layer } = setupLayer(config, noopPortLease(config.ports)); - - return Effect.gen(function* () { - const stack = yield* Stack; - const error = yield* stack.start.pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "StackBuildError", - reason: "invalid_config", - }); - if (Predicate.isTagged(error, "StackBuildError")) - expect(error.detail).toContain("edge-runtime"); - }).pipe(Effect.provide(layer)); - }); - it.effect("getState fails for internal helper services", () => { const { layer } = setupLayer(); @@ -1566,6 +1570,8 @@ describe("Stack", () => { port: defaultPorts.mailpitPort, smtpPort: defaultPorts.mailpitSmtpPort, pop3Port: defaultPorts.mailpitPop3Port, + dataDir: "/tmp/stack/mailpit", + dataDirIsAutoManaged: false, version: DEFAULT_VERSIONS.mailpit, adminEmail: "admin@example.com", senderName: "Admin", diff --git a/packages/stack/src/StackBuilder.integration.test.ts b/packages/stack/src/StackBuilder.integration.test.ts new file mode 100644 index 0000000000..fd834ce509 --- /dev/null +++ b/packages/stack/src/StackBuilder.integration.test.ts @@ -0,0 +1,343 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- This scenario exercises the public graph build with temporary native roots and reads generated configuration as a consumer would. + +import { describe, expect, it } from "vitest"; +import { NodeFileSystem } from "@effect/platform-node"; +import { Effect, Layer, Scope } from "effect"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; +import { mockChildProcessSpawner } from "../../process-compose/tests/helpers/mocks.ts"; +import { StackBuilder } from "./StackBuilder.ts"; +import { StackPreparation } from "./StackPreparation.ts"; +import type { StackPreparationInput } from "./StackPreparation.ts"; +import { resolveConfig } from "./StackConfigResolver.ts"; +import { SERVICE_NAMES } from "./ServiceCatalog.ts"; +import { nativeLogRoot, nativeServiceLogPath } from "./NativeLogWriter.ts"; +import type { AllocatedPorts } from "./PortCatalog.ts"; + +const ports: AllocatedPorts = { + apiPort: 41000, + dbPort: 41001, + authPort: 41002, + postgrestPort: 41003, + postgrestAdminPort: 41004, + edgeRuntimePort: 41005, + edgeRuntimeInspectorPort: 41006, + realtimePort: 41007, + storagePort: 41008, + imgproxyPort: 41009, + mailpitPort: 41010, + mailpitSmtpPort: 41011, + mailpitPop3Port: 41012, + pgmetaPort: 41013, + studioPort: 41014, + analyticsPort: 41015, + vectorAdminPort: 41016, + poolerSessionPort: 41017, + poolerTransactionPort: 41018, + poolerApiPort: 41019, + poolerInternalPort: 41020, +}; + +const binaryRoots: Record = Object.fromEntries( + SERVICE_NAMES.map((service) => [service, `/opt/slim/${service}`]), +); + +const fullNativeConfig = { + mode: "native" as const, + edgeRuntime: {}, + realtime: {}, + storage: {}, + imgproxy: {}, + mailpit: {}, + pgmeta: {}, + studio: {}, + analytics: {}, + vector: {}, + pooler: {}, +}; + +describe("StackBuilder native graph", () => { + it("builds the complete public graph with isolated native companions and config", async () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-builder-")); + const resolver = mockBinaryResolver({ binaries: binaryRoots }); + const spawner = mockChildProcessSpawner(); + const layer = Layer.mergeAll( + StackBuilder.layer, + NodeFileSystem.layer, + StackPreparation.layer.pipe(Layer.provide(resolver.layer), Layer.provide(spawner.layer)), + ); + + try { + const built = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const config = yield* resolveConfig(fullNativeConfig, { + ports, + stackRoot: join(root, "stack"), + runtimeRoot: join(root, "runtime"), + runtime: { mode: "native", containerRuntime: null }, + }); + const preparation = yield* StackPreparation; + const builder = yield* StackBuilder; + const input: StackPreparationInput = { + mode: "native", + services: SERVICE_NAMES, + versions: {}, + }; + const prepared = yield* preparation.prepare(input); + const scope = yield* Effect.scope; + return { + result: yield* builder + .build(config, prepared, { beamReleaseCookie: "stack-release-cookie" }) + .pipe(Effect.provideService(Scope.Scope, scope)), + storageDataDir: config.storage === false ? "" : config.storage.dataDir, + }; + }).pipe(Effect.provide(layer)), + ), + ); + + const { result, storageDataDir } = built; + const names = result.graph.startOrder.map((service) => service.name); + expect(names).toEqual( + expect.arrayContaining([ + ...SERVICE_NAMES, + "postgres-init", + "realtime-migrate", + "realtime-seed", + "analytics-migrate", + "analytics-seed", + "pooler-migrate", + "pooler-bootstrap", + ]), + ); + expect(names).toHaveLength(20); + expect(result.cleanupTargets.dockerContainerNames).toEqual([]); + + for (const service of SERVICE_NAMES) { + const definition = result.graph.startOrder.find((candidate) => candidate.name === service); + expect(definition, service).toBeDefined(); + expect(definition?.command, service).not.toMatch(/^(docker|podman)$/); + if (service !== "postgres") expect(definition?.command, service).toContain("/bin/"); + } + expect( + result.graph.startOrder.find((service) => service.name === "storage")?.env, + ).toMatchObject({ + SERVER_HOST: "127.0.0.1", + DATABASE_URL: `postgresql://supabase_storage_admin:postgres@127.0.0.1:${ports.dbPort}/postgres`, + IMGPROXY_URL: `http://127.0.0.1:${ports.imgproxyPort}`, + }); + expect( + result.graph.startOrder.find((service) => service.name === "imgproxy")?.env, + ).toMatchObject({ + IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", + IMGPROXY_ALLOWED_SOURCES: `local:///${storageDataDir.replace(/\/+$/u, "")}/`, + }); + expect(result.graph.startOrder.find((service) => service.name === "auth")?.env).toMatchObject( + { + GOTRUE_SMTP_HOST: "127.0.0.1", + GOTRUE_SMTP_PORT: String(ports.mailpitSmtpPort), + }, + ); + expect(result.graph.startOrder.find((service) => service.name === "analytics")?.args).toEqual( + ["start", "--sname", "logflare_41000"], + ); + for (const service of ["realtime", "analytics", "pooler"] as const) { + expect( + result.graph.startOrder.find((candidate) => candidate.name === service)?.env, + ).toMatchObject({ + ERL_EPMD_ADDRESS: "127.0.0.1", + ERL_AFLAGS: "-proto_dist inet_tcp -kernel inet_dist_use_interface '{127,0,0,1}'", + RELEASE_COOKIE: "stack-release-cookie", + }); + } + expect( + result.graph.startOrder.find((service) => service.name === "studio")?.env, + ).toMatchObject({ + HOSTNAME: "127.0.0.1", + STUDIO_PG_META_URL: `http://127.0.0.1:${ports.pgmetaPort}`, + LOGFLARE_URL: `http://127.0.0.1:${ports.analyticsPort}`, + }); + expect( + result.graph.startOrder.find((service) => service.name === "vector")?.healthCheck?.probe, + ).toEqual({ + _tag: "Http", + host: "127.0.0.1", + port: ports.vectorAdminPort, + path: "/health", + scheme: "http", + }); + expect( + result.graph.startOrder.find((service) => service.name === "pooler")?.env, + ).toMatchObject({ + PORT: String(ports.poolerApiPort), + PROXY_PORT_SESSION: String(ports.poolerSessionPort), + PROXY_PORT_TRANSACTION: String(ports.poolerTransactionPort), + SESSION_PROXY_PORTS: Array.from({ length: 4 }, (_, index) => + String(ports.poolerInternalPort + index), + ).join(","), + TRANSACTION_PROXY_PORTS: Array.from({ length: 4 }, (_, index) => + String(ports.poolerInternalPort + 4 + index), + ).join(","), + NODE_NAME: `pooler_${ports.apiPort}`, + NODE_IP: "127.0.0.1", + }); + expect(result.serviceProjection.get("realtime-migrate")).toEqual({ + visibility: "internal", + owner: "realtime", + ownerStatusWhileActive: "Initializing", + }); + expect(result.serviceProjection.get("analytics-migrate")).toEqual({ + visibility: "internal", + owner: "analytics", + ownerStatusWhileActive: "Initializing", + }); + expect(result.serviceProjection.get("analytics-seed")).toEqual({ + visibility: "internal", + owner: "analytics", + ownerStatusWhileActive: "Initializing", + }); + expect(result.serviceProjection.get("pooler-bootstrap")).toEqual({ + visibility: "internal", + owner: "pooler", + ownerStatusWhileActive: "Initializing", + }); + expect(result.serviceProjection.get("storage")).toEqual({ visibility: "public" }); + + const vectorConfig = readFileSync(join(root, "runtime", "vector", "vector.yaml"), "utf8"); + expect(vectorConfig).toContain(`http://127.0.0.1:${ports.analyticsPort}/api/logs`); + expect(vectorConfig).toContain(nativeLogRoot(join(root, "runtime"))); + expect(vectorConfig).toContain(nativeServiceLogPath(join(root, "runtime"), "vector")); + expect(vectorConfig).toContain("exclude:"); + expect(vectorConfig).toContain("native_logs"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("derives distinct valid Analytics short node names for sibling identities", async () => { + const roots: Array = []; + const buildAnalytics = async ( + instanceId: string, + apiPort: number, + ): Promise> => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-builder-identity-")); + roots.push(root); + const resolver = mockBinaryResolver({ binaries: binaryRoots }); + const spawner = mockChildProcessSpawner(); + const layer = Layer.mergeAll( + StackBuilder.layer, + NodeFileSystem.layer, + StackPreparation.layer.pipe(Layer.provide(resolver.layer), Layer.provide(spawner.layer)), + ); + return Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const config = yield* resolveConfig( + { ...fullNativeConfig, instanceId }, + { + ports: { ...ports, apiPort }, + stackRoot: join(root, "stack"), + runtimeRoot: join(root, "runtime"), + runtime: { mode: "native", containerRuntime: null }, + }, + ); + const preparation = yield* StackPreparation; + const builder = yield* StackBuilder; + const prepared = yield* preparation.prepare({ + mode: "native", + services: SERVICE_NAMES, + versions: {}, + }); + const scope = yield* Effect.scope; + const result = yield* builder + .build(config, prepared) + .pipe(Effect.provideService(Scope.Scope, scope)); + return ( + result.graph.startOrder.find((service) => service.name === "analytics")?.args ?? [] + ); + }).pipe(Effect.provide(layer)), + ), + ); + }; + + try { + const first = await buildAnalytics("stack-a", 42000); + const second = await buildAnalytics("stack-b", 42001); + const maximum = await buildAnalytics(`a${"-".repeat(63)}`, 42002); + expect(first).toEqual(["start", "--sname", "logflare_id_stack_a"]); + expect(second).toEqual(["start", "--sname", "logflare_id_stack_b"]); + expect(first).not.toEqual(second); + expect(maximum[2]).toBe(`logflare_id_a${"_".repeat(63)}`); + expect(maximum[2]).toMatch(/^[A-Za-z0-9_]+$/); + expect(maximum[2]?.length).toBeLessThanOrEqual(255); + } finally { + for (const root of roots) rmSync(root, { recursive: true, force: true }); + } + }); + + it("keeps Docker Auth pointed at the container-to-host Mailpit address", async () => { + const root = mkdtempSync(join(tmpdir(), "supabase-stack-builder-docker-smtp-")); + const resolver = mockBinaryResolver({ binaries: binaryRoots }); + const spawner = mockChildProcessSpawner(); + const layer = Layer.mergeAll( + StackBuilder.layer, + NodeFileSystem.layer, + StackPreparation.layer.pipe(Layer.provide(resolver.layer), Layer.provide(spawner.layer)), + ); + + try { + const result = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const config = yield* resolveConfig( + { + mode: "docker", + postgrest: false, + auth: {}, + edgeRuntime: false, + realtime: false, + storage: false, + imgproxy: false, + mailpit: {}, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, + }, + { + ports, + stackRoot: join(root, "stack"), + runtimeRoot: join(root, "runtime"), + runtime: { mode: "docker", containerRuntime: "docker" }, + }, + ); + const preparation = yield* StackPreparation; + const builder = yield* StackBuilder; + const prepared = yield* preparation.prepare({ + mode: "docker", + containerRuntime: "docker", + services: ["postgres", "auth", "mailpit"], + versions: {}, + }); + const scope = yield* Effect.scope; + return yield* builder + .build(config, prepared) + .pipe(Effect.provideService(Scope.Scope, scope)); + }).pipe(Effect.provide(layer)), + ), + ); + + expect(result.graph.startOrder.find((service) => service.name === "auth")?.env).toMatchObject( + { + GOTRUE_SMTP_HOST: "host.docker.internal", + GOTRUE_SMTP_PORT: String(ports.mailpitSmtpPort), + }, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 30de853c92..b969ba7119 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -8,42 +8,49 @@ import { StackBuildError } from "./errors.ts"; import { generateJwks } from "./JwtGenerator.ts"; import { detectPlatform, dockerHostAddress } from "./Platform.ts"; import { shortTempPrefixRoot } from "./paths.ts"; -import { makeAnalyticsServiceDocker } from "./services/analytics.ts"; import { makeAuthServiceDocker, makeAuthServiceNative } from "./services/auth.ts"; import { makeEdgeRuntimeServiceDocker, + makeEdgeRuntimeServiceNative, prepareEdgeRuntimeBootstrap, } from "./services/edge-runtime.ts"; -import { makeImgproxyServiceDocker } from "./services/imgproxy.ts"; -import { makeMailpitServiceDocker } from "./services/mailpit.ts"; -import { makePgmetaServiceDocker } from "./services/pgmeta.ts"; -import { makePoolerServiceDocker } from "./services/pooler.ts"; +import { makeImgproxyServiceDocker, makeImgproxyServiceNative } from "./services/imgproxy.ts"; +import { makeMailpitServiceDocker, makeMailpitServiceNative } from "./services/mailpit.ts"; +import { makePgmetaServiceDocker, makePgmetaServiceNative } from "./services/pgmeta.ts"; +import { makePoolerServiceDocker, makePoolerServicesNative } from "./services/pooler.ts"; import { makePostgresInitService, makePostgresInitServiceDocker, } from "./services/postgres-init.ts"; import { makePostgresService, makePostgresServiceDocker } from "./services/postgres.ts"; import { makePostgrestService, makePostgrestServiceDocker } from "./services/postgrest.ts"; -import { makeRealtimeServiceDocker } from "./services/realtime.ts"; +import { makeRealtimeServiceDocker, makeRealtimeServicesNative } from "./services/realtime.ts"; import { type ServiceDependency } from "./services/service-utils.ts"; import { LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, makeStorageServiceDocker, + makeStorageServiceNative, } from "./services/storage.ts"; -import { makeStudioServiceDocker } from "./services/studio.ts"; -import { makeVectorServiceDocker } from "./services/vector.ts"; +import { makeStudioServiceDocker, makeStudioServiceNative } from "./services/studio.ts"; +import { + makeVectorServiceDocker, + makeVectorServiceNative, + prepareVectorConfig, +} from "./services/vector.ts"; +import { makeAnalyticsServiceDocker, makeAnalyticsServicesNative } from "./services/analytics.ts"; import { dependencyTimeoutSecondsForServices, POSTGRES_INIT_COMPLETION_BUDGET_SECONDS, } from "./services/health-budgets.ts"; import type { PreparedStackArtifacts, ServiceResolution } from "./StackPreparation.ts"; import type { StackServiceProjectionCatalog } from "./StackStateProjection.ts"; -import { SERVICE_NAMES, serviceMetadata } from "./ServiceCatalog.ts"; +import { SERVICE_NAMES, serviceMetadata, type ServiceConfigKey } from "./ServiceCatalog.ts"; import type { ServiceName } from "./ServiceName.ts"; import { INSTANCE_ID_PATTERN, type ResolvedStackConfig } from "./StackConfig.ts"; import { dockerContainerName, stackIdentity } from "./StackIdentity.ts"; import type { VersionManifest } from "./versions.ts"; +import { prepareNativeDirectory } from "./native-filesystem.ts"; export interface BuildResult { readonly graph: ResolvedGraph; @@ -51,12 +58,10 @@ export interface BuildResult { readonly serviceProjection: StackServiceProjectionCatalog; } -const dockerOnlyServices = SERVICE_NAMES.filter( - (service) => serviceMetadata(service).runtimeSupport === "docker-only", -); -const nativeServices = SERVICE_NAMES.filter( - (service) => serviceMetadata(service).runtimeSupport !== "docker-only", -); +/** Secrets owned by one in-process stack runtime while its native graph is built. */ +export interface StackBuilderBuildOptions { + readonly beamReleaseCookie?: string; +} // Serial health-check paths used by dependency waits; keep each path aligned // with the corresponding service's transitive dependencies. @@ -64,6 +69,12 @@ const postgresStartupPath: ReadonlyArray = ["postgres"]; const storageStartupPath: ReadonlyArray = ["postgres", "storage"]; const analyticsStartupPath: ReadonlyArray = ["postgres", "analytics"]; +/** Derive a stack-unique, valid Erlang short node name from the owned identity. */ +const analyticsNodeName = (identityKey: string): string => + `logflare_${identityKey.replaceAll("-", "_")}`; +const beamNodeName = (service: "realtime" | "pooler", identityKey: string): string => + `${service}_${identityKey.replaceAll("-", "_")}`; + const postgresDependencyTimeoutSeconds = dependencyTimeoutSecondsForServices(postgresStartupPath); const postgresDependencies: ReadonlyArray = [ @@ -72,23 +83,38 @@ const postgresDependencies: ReadonlyArray = [ const publicServiceProjection = ( defs: ReadonlyArray, -): StackServiceProjectionCatalog => { - const serviceProjection: Map< + privateOwners: ReadonlyMap, +): Effect.Effect => { + const serviceProjection = new Map< string, { visibility: "public" | "internal"; owner?: string; ownerStatusWhileActive?: "Initializing"; } - > = new Map(defs.map((def) => [def.name, { visibility: "public" as const }] as const)); - - serviceProjection.set("postgres-init", { - visibility: "internal", - owner: "postgres", - ownerStatusWhileActive: "Initializing", - }); - - return serviceProjection; + >(); + for (const def of defs) { + if (SERVICE_NAMES.some((service) => service === def.name)) { + serviceProjection.set(def.name, { visibility: "public" }); + continue; + } + const owner = privateOwners.get(def.name); + if (owner !== undefined) { + serviceProjection.set(def.name, { + visibility: "internal", + owner, + ownerStatusWhileActive: "Initializing", + }); + continue; + } + return Effect.fail( + new StackBuildError({ + detail: `Service ${def.name} is not classified as public or private`, + reason: "invalid_config", + }), + ); + } + return Effect.succeed(serviceProjection); }; const hasAutoManagedPath = (config: ResolvedStackConfig, path: string) => @@ -99,8 +125,23 @@ const hasAutoManagedPath = (config: ResolvedStackConfig, path: string) => path.startsWith(`${managedPath}\\`), ); -const resolvedConfigForService = (config: ResolvedStackConfig, service: ServiceName) => - config[serviceMetadata(service).configKey]; +const resolvedConfigForService = ( + config: ResolvedStackConfig, + service: Name, +): ResolvedStackConfig[ServiceConfigKey] => config[serviceMetadata(service).configKey]; + +type EnabledResolvedConfig = ResolvedStackConfig & { + readonly [Key in ServiceConfigKey]: Exclude; +}; + +const configuredServiceEnabled = ( + config: ResolvedStackConfig, + service: Name, +): config is EnabledResolvedConfig => + config.servicePolicies[service] !== "off" && resolvedConfigForService(config, service) !== false; + +export const enabledServicesForConfig = (config: ResolvedStackConfig): ReadonlyArray => + SERVICE_NAMES.filter((service) => configuredServiceEnabled(config, service)); const prepareNativePostgresAlias = ( preparedPath: string, @@ -154,33 +195,27 @@ export const validateResolvedConfig = ( }); } - if (config.runtime.mode === "native") { - const enabledDockerOnly = dockerOnlyServices.filter( - (service) => resolvedConfigForService(config, service) !== false, - ); - if (enabledDockerOnly.length > 0) { - return yield* new StackBuildError({ - detail: `Native mode supports only ${nativeServices.join(", ")}. Disable ${enabledDockerOnly.join(", ")} or select Docker mode with a usable Docker or Podman runtime.`, - reason: "invalid_config", - }); - } - } - - if (config.imgproxy !== false && config.storage === false) { + if ( + configuredServiceEnabled(config, "imgproxy") && + !configuredServiceEnabled(config, "storage") + ) { return yield* new StackBuildError({ detail: "imgproxy requires storage to be enabled", reason: "invalid_config", }); } - if (config.vector !== false && config.analytics === false) { + if ( + configuredServiceEnabled(config, "vector") && + !configuredServiceEnabled(config, "analytics") + ) { return yield* new StackBuildError({ detail: "vector requires analytics to be enabled", reason: "invalid_config", }); } - if (config.studio !== false && config.pgmeta === false) { + if (configuredServiceEnabled(config, "studio") && !configuredServiceEnabled(config, "pgmeta")) { return yield* new StackBuildError({ detail: "studio requires pgmeta to be enabled", reason: "invalid_config", @@ -188,13 +223,6 @@ export const validateResolvedConfig = ( } }); -export const enabledServicesForConfig = (config: ResolvedStackConfig): ReadonlyArray => - SERVICE_NAMES.filter( - (service) => - config.servicePolicies?.[service] !== "off" && - resolvedConfigForService(config, service) !== false, - ); - export const versionsForConfig = (config: ResolvedStackConfig): Partial => { const versions: Partial> = {}; for (const service of enabledServicesForConfig(config)) { @@ -220,33 +248,22 @@ const requirePreparedResolution = ( ); }; -const requirePreparedDockerImage = ( - prepared: PreparedStackArtifacts, - service: ServiceName, -): Effect.Effect => - requirePreparedResolution(prepared, service).pipe( - Effect.flatMap((resolution) => - resolution.type === "docker" - ? Effect.succeed(resolution.image) - : Effect.fail( - new StackBuildError({ - detail: `Expected a docker image for ${service}`, - }), - ), - ), - ); - export class StackBuilder extends Context.Service< StackBuilder, { readonly build: ( config: ResolvedStackConfig, prepared: PreparedStackArtifacts, + options?: StackBuilderBuildOptions, ) => Effect.Effect; } >()("local/StackBuilder") { static layer: Layer.Layer = Layer.succeed(this, { - build: (config: ResolvedStackConfig, prepared: PreparedStackArtifacts) => + build: ( + config: ResolvedStackConfig, + prepared: PreparedStackArtifacts, + options?: StackBuilderBuildOptions, + ) => Effect.gen(function* () { yield* validateResolvedConfig(config); @@ -280,13 +297,19 @@ export class StackBuilder extends Context.Service< ); } - const authResolution = - config.auth === false ? false : yield* requirePreparedResolution(prepared, "auth"); + const authResolution = !configuredServiceEnabled(config, "auth") + ? false + : yield* requirePreparedResolution(prepared, "auth"); - const postgrestResolution = - config.postgrest === false - ? false - : yield* requirePreparedResolution(prepared, "postgrest"); + const postgrestResolution = !configuredServiceEnabled(config, "postgrest") + ? false + : yield* requirePreparedResolution(prepared, "postgrest"); + const mailpitEnabled = configuredServiceEnabled(config, "mailpit"); + const mailpitSmtpHost = mailpitEnabled ? serviceHost : undefined; + const nativeMailpitSmtpHost = mailpitEnabled ? "127.0.0.1" : undefined; + const mailpitSmtpPort = mailpitEnabled ? config.mailpit.smtpPort : undefined; + const mailpitAdminEmail = mailpitEnabled ? config.mailpit.adminEmail : undefined; + const mailpitSenderName = mailpitEnabled ? config.mailpit.senderName : undefined; const postgresInitCompletionBudgetSeconds = POSTGRES_INIT_COMPLETION_BUDGET_SECONDS; const postgresConsumerDependencyTimeoutSeconds = @@ -299,6 +322,10 @@ export class StackBuilder extends Context.Service< postgresInitCompletionBudgetSeconds; const jwtJwks = generateJwks(config.jwtSecret); const identity = stackIdentity(config); + // The owning LocalStack supplies one secret for its entire runtime. Direct builder + // consumers get an ephemeral fallback so native definitions remain self-contained. + const releaseCookie = + options?.beamReleaseCookie ?? `supabase_${identity.key.replaceAll("-", "")}_cookie`; const postgresService = postgresResolution.type === "binary" @@ -326,9 +353,10 @@ export class StackBuilder extends Context.Service< enabled: true, }, ]; + const privateOwners = new Map(); - defs.push({ - ...(postgresResolution.type === "binary" + const postgresInit = + postgresResolution.type === "binary" ? makePostgresInitService({ postgresDir: postgresResolution.path, dbPort: config.dbPort, @@ -345,12 +373,15 @@ export class StackBuilder extends Context.Service< autoExposeNewTables: config.postgres.autoExposeNewTables, identity, dependencies: [{ service: "postgres", condition: "healthy" }], - })), + }); + privateOwners.set(postgresInit.name, "postgres"); + defs.push({ + ...postgresInit, dependencyTimeoutSeconds: postgresDependencyTimeoutSeconds, enabled: true, }); - if (config.postgrest !== false && postgrestResolution !== false) { + if (configuredServiceEnabled(config, "postgrest") && postgrestResolution !== false) { defs.push({ ...(postgrestResolution.type === "binary" ? makePostgrestService({ @@ -383,7 +414,7 @@ export class StackBuilder extends Context.Service< }); } - if (config.auth !== false && authResolution !== false) { + if (configuredServiceEnabled(config, "auth") && authResolution !== false) { defs.push({ ...(authResolution.type === "binary" ? makeAuthServiceNative({ @@ -394,10 +425,10 @@ export class StackBuilder extends Context.Service< jwtSecret: config.jwtSecret, jwtExpiry: config.auth.jwtExpiry, externalUrl: config.auth.externalUrl, - smtpHost: config.mailpit !== false ? serviceHost : undefined, - smtpPort: config.mailpit !== false ? config.mailpit.smtpPort : undefined, - smtpAdminEmail: config.mailpit !== false ? config.mailpit.adminEmail : undefined, - smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, + smtpHost: nativeMailpitSmtpHost, + smtpPort: mailpitSmtpPort, + smtpAdminEmail: mailpitAdminEmail, + smtpSenderName: mailpitSenderName, dependencies: postgresDependencies, }) : makeAuthServiceDocker({ @@ -410,10 +441,10 @@ export class StackBuilder extends Context.Service< jwtSecret: config.jwtSecret, jwtExpiry: config.auth.jwtExpiry, externalUrl: config.auth.externalUrl, - smtpHost: config.mailpit !== false ? serviceHost : undefined, - smtpPort: config.mailpit !== false ? config.mailpit.smtpPort : undefined, - smtpAdminEmail: config.mailpit !== false ? config.mailpit.adminEmail : undefined, - smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, + smtpHost: mailpitSmtpHost, + smtpPort: mailpitSmtpPort, + smtpAdminEmail: mailpitAdminEmail, + smtpSenderName: mailpitSenderName, platformOs: platform.os, identity, dependencies: postgresDependencies, @@ -423,55 +454,83 @@ export class StackBuilder extends Context.Service< }); } - if (config.edgeRuntime !== false) { - const edgeRuntimeImage = yield* requirePreparedDockerImage(prepared, "edge-runtime"); + if (configuredServiceEnabled(config, "edge-runtime")) { + const edgeRuntimeResolution = yield* requirePreparedResolution(prepared, "edge-runtime"); const edgeRuntimeBootstrapDir = yield* prepareEdgeRuntimeBootstrap(config.runtimeRoot); defs.push({ - ...makeEdgeRuntimeServiceDocker({ - runtime: yield* requireContainerRuntime, - image: edgeRuntimeImage, - identity, - runtimeRoot: config.runtimeRoot, - bootstrapDir: edgeRuntimeBootstrapDir, - projectDir, - port: config.edgeRuntime.port, - inspectorPort: config.edgeRuntime.inspectorPort, - policy: config.edgeRuntime.policy, - env: config.edgeRuntime.env, - platformOs: platform.os, - dependencies: postgresDependencies, - }), + ...(edgeRuntimeResolution.type === "binary" + ? makeEdgeRuntimeServiceNative({ + binPath: edgeRuntimeResolution.path, + bootstrapDir: edgeRuntimeBootstrapDir, + projectDir, + port: config.edgeRuntime.port, + inspectorPort: config.edgeRuntime.inspectorPort, + policy: config.edgeRuntime.policy, + env: config.edgeRuntime.env, + dependencies: postgresDependencies, + }) + : makeEdgeRuntimeServiceDocker({ + runtime: yield* requireContainerRuntime, + image: edgeRuntimeResolution.image, + identity, + bootstrapDir: edgeRuntimeBootstrapDir, + projectDir, + port: config.edgeRuntime.port, + inspectorPort: config.edgeRuntime.inspectorPort, + policy: config.edgeRuntime.policy, + env: config.edgeRuntime.env, + platformOs: platform.os, + dependencies: postgresDependencies, + })), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, }); } - if (config.mailpit !== false) { - const mailpitImage = yield* requirePreparedDockerImage(prepared, "mailpit"); + if (configuredServiceEnabled(config, "mailpit")) { + const mailpitResolution = yield* requirePreparedResolution(prepared, "mailpit"); + const mailpitDataDir = config.mailpit.dataDir; defs.push({ - ...makeMailpitServiceDocker({ - runtime: yield* requireContainerRuntime, - image: mailpitImage, - identity, - webPort: config.mailpit.port, - smtpPort: config.mailpit.smtpPort, - pop3Port: config.mailpit.pop3Port, - platformOs: platform.os, - dependencies: [], - }), + ...(mailpitResolution.type === "binary" + ? makeMailpitServiceNative({ + binPath: mailpitResolution.path, + dataDir: mailpitDataDir, + webPort: config.mailpit.port, + smtpPort: config.mailpit.smtpPort, + pop3Port: config.mailpit.pop3Port, + cleanupDataDirOnExit: + config.mailpit.dataDirIsAutoManaged && + hasAutoManagedPath(config, mailpitDataDir), + dependencies: [], + }) + : makeMailpitServiceDocker({ + runtime: yield* requireContainerRuntime, + image: mailpitResolution.image, + identity, + webPort: config.mailpit.port, + smtpPort: config.mailpit.smtpPort, + pop3Port: config.mailpit.pop3Port, + platformOs: platform.os, + dependencies: [], + })), enabled: true, }); + if (mailpitResolution.type === "binary") { + yield* prepareNativeDirectory( + mailpitDataDir, + "Failed to prepare the native Mailpit data directory", + ); + } } - if (config.realtime !== false) { - const realtimeImage = yield* requirePreparedDockerImage(prepared, "realtime"); - defs.push({ - ...makeRealtimeServiceDocker({ - runtime: yield* requireContainerRuntime, - image: realtimeImage, + if (configuredServiceEnabled(config, "realtime")) { + const realtimeResolution = yield* requirePreparedResolution(prepared, "realtime"); + if (realtimeResolution.type === "binary") { + const bundle = makeRealtimeServicesNative({ + binPath: realtimeResolution.path, + nodeName: beamNodeName("realtime", identity.key), + releaseCookie, port: config.realtime.port, - identity, - dbHost: serviceHost, dbPort: config.dbPort, jwtSecret: config.jwtSecret, jwtJwks, @@ -479,129 +538,293 @@ export class StackBuilder extends Context.Service< encryptionKey: config.realtime.encryptionKey, secretKeyBase: config.realtime.secretKeyBase, maxHeaderLength: config.realtime.maxHeaderLength, - platformOs: platform.os, dependencies: postgresDependencies, - }), - dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, - enabled: true, - }); + }); + privateOwners.set(bundle.migrate.name, "realtime"); + privateOwners.set(bundle.seed.name, "realtime"); + defs.push( + { + ...bundle.migrate, + dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, + enabled: true, + }, + { + ...bundle.seed, + dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, + enabled: true, + }, + { + ...bundle.server, + dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, + enabled: true, + }, + ); + } else { + defs.push({ + ...makeRealtimeServiceDocker({ + runtime: yield* requireContainerRuntime, + image: realtimeResolution.image, + port: config.realtime.port, + identity, + dbHost: serviceHost, + dbPort: config.dbPort, + jwtSecret: config.jwtSecret, + jwtJwks, + tenantId: config.realtime.tenantId, + encryptionKey: config.realtime.encryptionKey, + secretKeyBase: config.realtime.secretKeyBase, + maxHeaderLength: config.realtime.maxHeaderLength, + platformOs: platform.os, + dependencies: postgresDependencies, + }), + dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, + enabled: true, + }); + } } - if (config.storage !== false) { - const storageImage = yield* requirePreparedDockerImage(prepared, "storage"); + if (configuredServiceEnabled(config, "storage")) { + const storageResolution = yield* requirePreparedResolution(prepared, "storage"); + const imgproxyEnabled = configuredServiceEnabled(config, "imgproxy"); + const imgproxyPort = config.imgproxy === false ? 0 : config.imgproxy.port; + if (storageResolution.type === "binary") { + yield* prepareNativeDirectory( + config.storage.dataDir, + "Failed to prepare the native Storage data directory", + ); + } defs.push({ - ...makeStorageServiceDocker({ - runtime: yield* requireContainerRuntime, - image: storageImage, - port: config.storage.port, - identity, - dbHost: serviceHost, - dbPort: config.dbPort, - dataDir: config.storage.dataDir, - anonKey: config.publishableKey, - serviceKey: config.secretKey, - jwtSecret: config.jwtSecret, - jwtJwks, - fileSizeLimit: config.storage.fileSizeLimit, - enableImageTransformation: config.imgproxy !== false, - imgproxyUrl: - config.imgproxy !== false ? `http://${serviceHost}:${config.imgproxy.port}` : "", - s3ProtocolEnabled: config.storage.s3ProtocolEnabled, - platformOs: platform.os, - dependencies: postgresDependencies, - cleanupDataDirOnExit: hasAutoManagedPath(config, config.storage.dataDir), - }), + ...(storageResolution.type === "binary" + ? makeStorageServiceNative({ + binPath: storageResolution.path, + port: config.storage.port, + dbPort: config.dbPort, + dataDir: config.storage.dataDir, + anonKey: config.publishableKey, + serviceKey: config.secretKey, + jwtSecret: config.jwtSecret, + jwtJwks, + fileSizeLimit: config.storage.fileSizeLimit, + enableImageTransformation: imgproxyEnabled, + imgproxyUrl: imgproxyEnabled ? `http://127.0.0.1:${imgproxyPort}` : "", + s3ProtocolEnabled: config.storage.s3ProtocolEnabled, + dependencies: postgresDependencies, + cleanupDataDirOnExit: hasAutoManagedPath(config, config.storage.dataDir), + }) + : makeStorageServiceDocker({ + runtime: yield* requireContainerRuntime, + image: storageResolution.image, + port: config.storage.port, + identity, + dbHost: serviceHost, + dbPort: config.dbPort, + dataDir: config.storage.dataDir, + anonKey: config.publishableKey, + serviceKey: config.secretKey, + jwtSecret: config.jwtSecret, + jwtJwks, + fileSizeLimit: config.storage.fileSizeLimit, + enableImageTransformation: imgproxyEnabled, + imgproxyUrl: imgproxyEnabled ? `http://${serviceHost}:${imgproxyPort}` : "", + s3ProtocolEnabled: config.storage.s3ProtocolEnabled, + platformOs: platform.os, + dependencies: postgresDependencies, + cleanupDataDirOnExit: hasAutoManagedPath(config, config.storage.dataDir), + })), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, }); } - if (config.imgproxy !== false) { + if (configuredServiceEnabled(config, "imgproxy")) { const storageConfig = config.storage; - const imgproxyImage = yield* requirePreparedDockerImage(prepared, "imgproxy"); + // validateResolvedConfig enforces this dependency; keep the builder fail-loud if it diverges. + if (storageConfig === false) { + return yield* new StackBuildError({ + detail: "imgproxy requires storage to be enabled", + reason: "invalid_config", + }); + } + const imgproxyResolution = yield* requirePreparedResolution(prepared, "imgproxy"); defs.push({ - ...makeImgproxyServiceDocker({ - runtime: yield* requireContainerRuntime, - image: imgproxyImage, - port: config.imgproxy.port, - identity, - dataDir: storageConfig === false ? "" : storageConfig.dataDir, - platformOs: platform.os, - dependencies: [{ service: "storage", condition: "healthy" }], - }), + ...(imgproxyResolution.type === "binary" + ? makeImgproxyServiceNative({ + binPath: imgproxyResolution.path, + port: config.imgproxy.port, + dataDir: storageConfig.dataDir, + dependencies: [{ service: "storage", condition: "healthy" }], + }) + : makeImgproxyServiceDocker({ + runtime: yield* requireContainerRuntime, + image: imgproxyResolution.image, + port: config.imgproxy.port, + identity, + dataDir: storageConfig.dataDir, + platformOs: platform.os, + dependencies: [{ service: "storage", condition: "healthy" }], + })), dependencyTimeoutSeconds: storageDependencyTimeoutSeconds, enabled: true, }); } - if (config.pgmeta !== false) { - const pgmetaImage = yield* requirePreparedDockerImage(prepared, "pgmeta"); + if (configuredServiceEnabled(config, "pgmeta")) { + const pgmetaResolution = yield* requirePreparedResolution(prepared, "pgmeta"); defs.push({ - ...makePgmetaServiceDocker({ - runtime: yield* requireContainerRuntime, - image: pgmetaImage, - identity, - port: config.pgmeta.port, - dbHost: serviceHost, - dbPort: config.dbPort, - platformOs: platform.os, - dependencies: postgresDependencies, - }), + ...(pgmetaResolution.type === "binary" + ? makePgmetaServiceNative({ + binPath: pgmetaResolution.path, + port: config.pgmeta.port, + dbPort: config.dbPort, + dependencies: postgresDependencies, + }) + : makePgmetaServiceDocker({ + runtime: yield* requireContainerRuntime, + image: pgmetaResolution.image, + identity, + port: config.pgmeta.port, + dbHost: serviceHost, + dbPort: config.dbPort, + platformOs: platform.os, + dependencies: postgresDependencies, + })), dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, enabled: true, }); } - if (config.analytics !== false) { - const analyticsImage = yield* requirePreparedDockerImage(prepared, "analytics"); - defs.push({ - ...makeAnalyticsServiceDocker({ - runtime: yield* requireContainerRuntime, - image: analyticsImage, - identity, + if (configuredServiceEnabled(config, "analytics")) { + const analyticsResolution = yield* requirePreparedResolution(prepared, "analytics"); + if (analyticsResolution.type === "binary") { + yield* prepareNativeDirectory( + join(config.runtimeRoot, "analytics"), + "Failed to prepare the native Analytics runtime directory", + ); + const bundle = makeAnalyticsServicesNative({ + binPath: analyticsResolution.path, + runtimeRoot: config.runtimeRoot, + nodeName: analyticsNodeName(identity.key), + releaseCookie, hostPort: config.analytics.port, - platformOs: platform.os, - dbHost: serviceHost, dbPort: config.dbPort, apiKey: config.analytics.apiKey, backend: config.analytics.backend, dependencies: postgresDependencies, - }), - dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, - enabled: true, - }); + }); + privateOwners.set(bundle.migrate.name, "analytics"); + privateOwners.set(bundle.seed.name, "analytics"); + defs.push( + { + ...bundle.migrate, + dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, + enabled: true, + }, + { + ...bundle.seed, + dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, + enabled: true, + }, + { + ...bundle.server, + dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, + enabled: true, + }, + ); + } else { + defs.push({ + ...makeAnalyticsServiceDocker({ + runtime: yield* requireContainerRuntime, + image: analyticsResolution.image, + identity, + hostPort: config.analytics.port, + platformOs: platform.os, + dbHost: serviceHost, + dbPort: config.dbPort, + apiKey: config.analytics.apiKey, + backend: config.analytics.backend, + dependencies: postgresDependencies, + }), + dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, + enabled: true, + }); + } } - if (config.vector !== false) { + if (configuredServiceEnabled(config, "vector")) { const analyticsConfig = config.analytics; - const vectorImage = yield* requirePreparedDockerImage(prepared, "vector"); - defs.push({ - ...makeVectorServiceDocker({ - runtime: yield* requireContainerRuntime, - image: vectorImage, - identity, - serviceHost, - analyticsPort: analyticsConfig === false ? 0 : analyticsConfig.port, - analyticsApiKey: analyticsConfig === false ? "api-key" : analyticsConfig.apiKey, - platformOs: platform.os, - dependencies: [{ service: "analytics", condition: "healthy" }], - }), - dependencyTimeoutSeconds: analyticsDependencyTimeoutSeconds, - enabled: true, - }); + const vectorResolution = yield* requirePreparedResolution(prepared, "vector"); + if (analyticsConfig === false) { + return yield* new StackBuildError({ + detail: "vector requires analytics to be enabled", + reason: "invalid_config", + }); + } + if (vectorResolution.type === "binary") { + const adminPort = config.vector.adminPort; + if (adminPort === undefined) { + return yield* new StackBuildError({ + detail: "Native Vector requires a resolved private admin port", + reason: "invalid_config", + }); + } + yield* prepareVectorConfig({ + runtimeRoot: config.runtimeRoot, + adminPort, + analyticsPort: analyticsConfig.port, + analyticsApiKey: analyticsConfig.apiKey, + }); + defs.push({ + ...makeVectorServiceNative({ + binPath: vectorResolution.path, + runtimeRoot: config.runtimeRoot, + adminPort, + analyticsPort: analyticsConfig.port, + analyticsApiKey: analyticsConfig.apiKey, + dependencies: [{ service: "analytics", condition: "healthy" }], + }), + dependencyTimeoutSeconds: analyticsDependencyTimeoutSeconds, + enabled: true, + }); + } else { + defs.push({ + ...makeVectorServiceDocker({ + runtime: yield* requireContainerRuntime, + image: vectorResolution.image, + identity, + serviceHost, + analyticsPort: analyticsConfig.port, + analyticsApiKey: analyticsConfig.apiKey, + platformOs: platform.os, + dependencies: [{ service: "analytics", condition: "healthy" }], + }), + dependencyTimeoutSeconds: analyticsDependencyTimeoutSeconds, + enabled: true, + }); + } } - if (config.pooler !== false) { - const poolerImage = yield* requirePreparedDockerImage(prepared, "pooler"); - defs.push({ - ...makePoolerServiceDocker({ - runtime: yield* requireContainerRuntime, - image: poolerImage, - identity, - hostAdminPort: config.pooler.apiPort, - hostPort: config.pooler.port, - platformOs: platform.os, - dbHost: serviceHost, + if (configuredServiceEnabled(config, "pooler")) { + const poolerResolution = yield* requirePreparedResolution(prepared, "pooler"); + if (poolerResolution.type === "binary") { + if (config.pooler.internalPort === undefined) { + return yield* new StackBuildError({ + detail: "Native Pooler requires an allocated internal listener span", + reason: "invalid_config", + }); + } + yield* prepareNativeDirectory( + join(config.runtimeRoot, "pooler"), + "Failed to prepare the native Pooler runtime directory", + ); + const bundle = makePoolerServicesNative({ + binPath: poolerResolution.path, + runtimeRoot: config.runtimeRoot, + adminPort: config.pooler.apiPort, + sessionPort: config.pooler.sessionPort, + transactionPort: config.pooler.transactionPort, + internalPort: config.pooler.internalPort, + nodeName: beamNodeName("pooler", identity.key), + releaseCookie, dbPort: config.dbPort, poolMode: config.pooler.mode, defaultPoolSize: config.pooler.defaultPoolSize, @@ -611,43 +834,112 @@ export class StackBuilder extends Context.Service< encryptionKey: config.pooler.encryptionKey, secretKeyBase: config.pooler.secretKeyBase, dependencies: postgresDependencies, - }), - dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, - enabled: true, - }); + }); + privateOwners.set(bundle.migrate.name, "pooler"); + privateOwners.set(bundle.bootstrap.name, "pooler"); + defs.push( + { + ...bundle.migrate, + dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, + enabled: true, + }, + { + ...bundle.bootstrap, + dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, + enabled: true, + }, + { + ...bundle.server, + dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, + enabled: true, + }, + ); + } else { + defs.push({ + ...makePoolerServiceDocker({ + runtime: yield* requireContainerRuntime, + image: poolerResolution.image, + identity, + hostAdminPort: config.pooler.apiPort, + hostSessionPort: config.pooler.sessionPort, + hostTransactionPort: config.pooler.transactionPort, + platformOs: platform.os, + dbHost: serviceHost, + dbPort: config.dbPort, + poolMode: config.pooler.mode, + defaultPoolSize: config.pooler.defaultPoolSize, + maxClientConn: config.pooler.maxClientConn, + jwtSecret: config.jwtSecret, + tenantId: config.pooler.tenantId, + encryptionKey: config.pooler.encryptionKey, + secretKeyBase: config.pooler.secretKeyBase, + dependencies: postgresDependencies, + }), + dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, + enabled: true, + }); + } } - if (config.studio !== false) { + if (configuredServiceEnabled(config, "studio")) { const pgmetaConfig = config.pgmeta; - const studioImage = yield* requirePreparedDockerImage(prepared, "studio"); + const studioResolution = yield* requirePreparedResolution(prepared, "studio"); + const analyticsConfig = config.analytics; + const pgmetaEnabled = configuredServiceEnabled(config, "pgmeta"); + const analyticsEnabled = configuredServiceEnabled(config, "analytics"); + const pgmetaPort = pgmetaConfig === false ? 0 : pgmetaConfig.port; + const analyticsPort = analyticsConfig === false ? 0 : analyticsConfig.port; + const analyticsBackend = analyticsConfig === false ? "postgres" : analyticsConfig.backend; + const analyticsApiKey = analyticsConfig === false ? "api-key" : analyticsConfig.apiKey; defs.push({ - ...makeStudioServiceDocker({ - runtime: yield* requireContainerRuntime, - image: studioImage, - identity, - port: config.studio.port, - apiUrl: config.studio.apiUrl, - publicApiUrl: `http://127.0.0.1:${config.apiPort}`, - pgmetaUrl: pgmetaConfig === false ? "" : `http://${serviceHost}:${pgmetaConfig.port}`, - publishableKey: config.publishableKey, - secretKey: config.secretKey, - s3ProtocolAccessKeyId: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, - s3ProtocolAccessKeySecret: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, - jwtSecret: config.jwtSecret, - analyticsEnabled: config.analytics !== false, - analyticsBackend: config.analytics !== false ? config.analytics.backend : "postgres", - analyticsUrl: - config.analytics !== false ? `http://${serviceHost}:${config.analytics.port}` : "", - analyticsApiKey: config.analytics !== false ? config.analytics.apiKey : "api-key", - platformOs: platform.os, - dependencies: - config.analytics === false - ? [{ service: "pgmeta", condition: "healthy" }] - : [ - { service: "pgmeta", condition: "healthy" }, - { service: "analytics", condition: "healthy" }, - ], - }), + ...(studioResolution.type === "binary" + ? makeStudioServiceNative({ + binPath: studioResolution.path, + port: config.studio.port, + apiUrl: config.studio.apiUrl, + publicApiUrl: `http://127.0.0.1:${config.apiPort}`, + pgmetaUrl: !pgmetaEnabled ? "" : `http://127.0.0.1:${pgmetaPort}`, + publishableKey: config.publishableKey, + secretKey: config.secretKey, + s3ProtocolAccessKeyId: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + s3ProtocolAccessKeySecret: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, + jwtSecret: config.jwtSecret, + analyticsEnabled, + analyticsBackend: analyticsEnabled ? analyticsBackend : "postgres", + analyticsUrl: !analyticsEnabled ? "" : `http://127.0.0.1:${analyticsPort}`, + analyticsApiKey: analyticsEnabled ? analyticsApiKey : "api-key", + dependencies: !analyticsEnabled + ? [{ service: "pgmeta", condition: "healthy" }] + : [ + { service: "pgmeta", condition: "healthy" }, + { service: "analytics", condition: "healthy" }, + ], + }) + : makeStudioServiceDocker({ + runtime: yield* requireContainerRuntime, + image: studioResolution.image, + identity, + port: config.studio.port, + apiUrl: config.studio.apiUrl, + publicApiUrl: `http://127.0.0.1:${config.apiPort}`, + pgmetaUrl: !pgmetaEnabled ? "" : `http://${serviceHost}:${pgmetaPort}`, + publishableKey: config.publishableKey, + secretKey: config.secretKey, + s3ProtocolAccessKeyId: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + s3ProtocolAccessKeySecret: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, + jwtSecret: config.jwtSecret, + analyticsEnabled, + analyticsBackend: analyticsEnabled ? analyticsBackend : "postgres", + analyticsUrl: analyticsEnabled ? `http://${serviceHost}:${analyticsPort}` : "", + analyticsApiKey: analyticsEnabled ? analyticsApiKey : "api-key", + platformOs: platform.os, + dependencies: !analyticsEnabled + ? [{ service: "pgmeta", condition: "healthy" }] + : [ + { service: "pgmeta", condition: "healthy" }, + { service: "analytics", condition: "healthy" }, + ], + })), dependencyTimeoutSeconds: analyticsDependencyTimeoutSeconds, enabled: true, }); @@ -677,7 +969,7 @@ export class StackBuilder extends Context.Service< cleanupTargets: { dockerContainerNames, }, - serviceProjection: publicServiceProjection(defs), + serviceProjection: yield* publicServiceProjection(defs, privateOwners), }; }), }); diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index b041ec5431..7241fc18e4 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -47,8 +47,11 @@ const basePorts: AllocatedPorts = { pgmetaPort: 3016, studioPort: 3017, analyticsPort: 3018, - poolerPort: 3019, - poolerApiPort: 3020, + vectorAdminPort: 3021, + poolerSessionPort: 3019, + poolerTransactionPort: 3020, + poolerApiPort: 3021, + poolerInternalPort: 3022, }; const baseConfig: ResolvedStackConfig = { diff --git a/packages/stack/src/StackConfig.ts b/packages/stack/src/StackConfig.ts index 0255dbc560..a364eb3e60 100644 --- a/packages/stack/src/StackConfig.ts +++ b/packages/stack/src/StackConfig.ts @@ -123,6 +123,7 @@ export interface MailpitConfig { readonly port?: number; readonly smtpPort?: number; readonly pop3Port?: number; + readonly dataDir?: string; readonly version?: string; readonly adminEmail?: string; readonly senderName?: string; @@ -151,7 +152,8 @@ export interface VectorConfig { } export interface PoolerConfig { - readonly port?: number; + readonly sessionPort?: number; + readonly transactionPort?: number; readonly apiPort?: number; readonly mode?: "transaction" | "session"; readonly version?: string; @@ -262,6 +264,9 @@ export interface ResolvedMailpitConfig { readonly port: number; readonly smtpPort: number; readonly pop3Port: number; + readonly dataDir: string; + /** Whether the data directory came from the package-managed stack root. */ + readonly dataDirIsAutoManaged: boolean; readonly version: string; readonly adminEmail: string; readonly senderName: string; @@ -287,11 +292,16 @@ export interface ResolvedAnalyticsConfig { export interface ResolvedVectorConfig { readonly version: string; + /** Native Vector's private administration/health listener; absent for Docker. */ + readonly adminPort?: number; } export interface ResolvedPoolerConfig { - readonly port: number; + readonly sessionPort: number; + readonly transactionPort: number; readonly apiPort: number; + /** Base port for native Supavisor's private session/transaction shard listeners. */ + readonly internalPort?: number; readonly mode: "transaction" | "session"; readonly version: string; readonly tenantId: string; diff --git a/packages/stack/src/StackConfigResolver.policy.unit.test.ts b/packages/stack/src/StackConfigResolver.policy.unit.test.ts index 696e34c320..67f530ad93 100644 --- a/packages/stack/src/StackConfigResolver.policy.unit.test.ts +++ b/packages/stack/src/StackConfigResolver.policy.unit.test.ts @@ -17,6 +17,8 @@ const testPorts: PortSet = { authPort: 40_002, postgrestPort: 40_003, postgrestAdminPort: 40_004, + edgeRuntimePort: 40_016, + edgeRuntimeInspectorPort: 40_017, realtimePort: 40_005, storagePort: 40_006, imgproxyPort: 40_007, @@ -26,8 +28,11 @@ const testPorts: PortSet = { pgmetaPort: 40_011, studioPort: 40_012, analyticsPort: 40_013, - poolerPort: 40_014, - poolerApiPort: 40_015, + vectorAdminPort: 40_018, + poolerSessionPort: 40_014, + poolerTransactionPort: 40_015, + poolerApiPort: 40_019, + poolerInternalPort: 40_020, }; const resolveConfig = ( @@ -86,6 +91,60 @@ describe("resolved service preparation policies", () => { expect(config.servicePolicies.mailpit).toBe("eager"); }); + it("enables Edge Runtime in native mode when omitted, matching Docker defaults", async () => { + const config = await resolveConfig(); + + expect(config.edgeRuntime).not.toBe(false); + expect(config.servicePolicies["edge-runtime"]).toBe("lazy"); + }); + + it("resolves Docker-specific Vector and Pooler defaults", async () => { + const config = await resolveConfig( + { mode: "docker", analytics: {}, vector: {}, pooler: {} }, + { runtime: { mode: "docker", containerRuntime: "docker" } }, + ); + + expect(config.vector).toMatchObject({ version: "0.53.0-alpine" }); + expect(config.pooler).toMatchObject({ version: "2.9.12" }); + expect(config.pooler).not.toHaveProperty("internalPort"); + }); + + it("resolves independent Pooler protocol ports and Mailpit data ownership", async () => { + const config = await resolveConfig( + { + pooler: { sessionPort: 41_001, transactionPort: 41_002, apiPort: 41_003 }, + mailpit: { dataDir: "/tmp/explicit-mailpit" }, + }, + { + ports: { + ...testPorts, + poolerSessionPort: 41_001, + poolerTransactionPort: 41_002, + poolerApiPort: 41_003, + }, + }, + ); + + expect(config.pooler).toMatchObject({ + sessionPort: 41_001, + transactionPort: 41_002, + apiPort: 41_003, + internalPort: 40_020, + }); + expect(config.mailpit).toMatchObject({ dataDir: "/tmp/explicit-mailpit" }); + }); + + it("normalizes explicit Pooler versions for the selected runtime", async () => { + const native = await resolveConfig({ pooler: { version: "2.9.10" } }); + expect(native.pooler).toMatchObject({ version: "v2.9.10" }); + + const docker = await resolveConfig( + { mode: "docker", pooler: { version: "v2.9.7" } }, + { runtime: { mode: "docker", containerRuntime: "docker" } }, + ); + expect(docker.pooler).toMatchObject({ version: "2.9.7" }); + }); + it("rejects an unsupported lazy policy before port allocation", async () => { await expect(resolveConfig({ servicePolicies: { postgres: "lazy" } })).rejects.toBeInstanceOf( StackBuildError, diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index 4c2a80b364..f5ac3b1f11 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -51,12 +51,17 @@ import type { import type { StackRuntimeSelection } from "./ContainerRuntime.ts"; import { DEFAULT_SERVICE_POLICIES, - DEFAULT_VERSIONS, SERVICE_CATALOG, SERVICE_NAMES, serviceMetadata, } from "./ServiceCatalog.ts"; import type { ServiceName } from "./ServiceName.ts"; +import { + defaultVersionsForRuntime, + normalizeServiceVersion, + type VersionManifest, + type VersionRuntime, +} from "./versions.ts"; export interface ResolveConfigOptions { /** Ports selected by the caller-owned lease. Resolution never allocates ports. */ @@ -66,6 +71,20 @@ export interface ResolveConfigOptions { readonly runtime?: StackRuntimeSelection; } +interface VersionResolutionOptions { + readonly defaults: VersionManifest; + readonly runtime: VersionRuntime; +} + +const resolveServiceVersion = ( + service: ServiceName, + configured: string | undefined, + options: VersionResolutionOptions, +): string => + configured === undefined + ? options.defaults[service] + : normalizeServiceVersion(service, configured, options.runtime); + interface ResolvedRoots { readonly cacheRoot: string; readonly stackRoot: string; @@ -159,6 +178,7 @@ function resolvePostgrestConfig( input: PostgrestConfig | undefined, raw: PostgrestConfig | false | undefined, ports: PortSet, + versions: VersionResolutionOptions, ): Effect.Effect { if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; @@ -172,7 +192,7 @@ function resolvePostgrestConfig( schemas: cfg.schemas ?? ["public", "graphql_public"], extraSearchPath: cfg.extraSearchPath ?? ["public", "extensions"], maxRows: cfg.maxRows ?? 1000, - version: cfg.version ?? DEFAULT_VERSIONS.postgrest, + version: resolveServiceVersion("postgrest", cfg.version, versions), })), ); } @@ -182,6 +202,7 @@ function resolveAuthConfig( raw: AuthConfig | false | undefined, ports: PortSet, apiPort: number, + versions: VersionResolutionOptions, ): Effect.Effect { if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; @@ -191,7 +212,7 @@ function resolveAuthConfig( siteUrl: cfg.siteUrl ?? "http://localhost:3000", jwtExpiry: cfg.jwtExpiry ?? 3600, externalUrl: cfg.externalUrl ?? `http://127.0.0.1:${apiPort}`, - version: cfg.version ?? DEFAULT_VERSIONS.auth, + version: resolveServiceVersion("auth", cfg.version, versions), })), ); } @@ -200,13 +221,14 @@ function resolveRealtimeConfig( input: RealtimeConfig | undefined, raw: RealtimeConfig | false | undefined, ports: PortSet, + versions: VersionResolutionOptions, ): Effect.Effect { if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; return requiredPort(ports, "realtimePort").pipe( Effect.map((port) => ({ port, - version: cfg.version ?? DEFAULT_VERSIONS.realtime, + version: resolveServiceVersion("realtime", cfg.version, versions), tenantId: cfg.tenantId ?? "realtime-dev", encryptionKey: cfg.encryptionKey ?? "supabaserealtime", secretKeyBase: @@ -220,6 +242,7 @@ function resolveEdgeRuntimeConfig( input: EdgeRuntimeConfig | undefined, raw: EdgeRuntimeConfig | false | undefined, ports: PortSet, + versions: VersionResolutionOptions, ): Effect.Effect { if (raw === false || raw?.enabled === false) return Effect.succeed(false); const cfg = input ?? {}; @@ -232,7 +255,7 @@ function resolveEdgeRuntimeConfig( port, inspectorPort, policy: cfg.policy ?? "per_worker", - version: cfg.version ?? DEFAULT_VERSIONS["edge-runtime"], + version: resolveServiceVersion("edge-runtime", cfg.version, versions), env: cfg.env ?? {}, })), ); @@ -280,13 +303,14 @@ function resolveStorageConfig( raw: StorageConfig | false | undefined, ports: PortSet, opts: ResolveConfigOptions, + versions: VersionResolutionOptions, ): Effect.Effect { if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; return requiredPort(ports, "storagePort").pipe( Effect.map((port) => ({ port, - version: cfg.version ?? DEFAULT_VERSIONS.storage, + version: resolveServiceVersion("storage", cfg.version, versions), dataDir: resolveDataDir(cfg.dataDir, opts.stackRoot!, "storage"), fileSizeLimit: cfg.fileSizeLimit ?? "50MiB", s3ProtocolEnabled: cfg.s3ProtocolEnabled ?? true, @@ -298,13 +322,14 @@ function resolveImgproxyConfig( input: ImgproxyConfig | undefined, raw: ImgproxyConfig | false | undefined, ports: PortSet, + versions: VersionResolutionOptions, ): Effect.Effect { if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; return requiredPort(ports, "imgproxyPort").pipe( Effect.map((port) => ({ port, - version: cfg.version ?? DEFAULT_VERSIONS.imgproxy, + version: resolveServiceVersion("imgproxy", cfg.version, versions), })), ); } @@ -313,6 +338,8 @@ function resolveMailpitConfig( input: MailpitConfig | undefined, raw: MailpitConfig | false | undefined, ports: PortSet, + stackRoot: string, + versions: VersionResolutionOptions, ): Effect.Effect { if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; @@ -325,7 +352,9 @@ function resolveMailpitConfig( port, smtpPort, pop3Port, - version: cfg.version ?? DEFAULT_VERSIONS.mailpit, + dataDir: resolveDataDir(cfg.dataDir, stackRoot, "mailpit"), + dataDirIsAutoManaged: cfg.dataDir === undefined, + version: resolveServiceVersion("mailpit", cfg.version, versions), adminEmail: cfg.adminEmail ?? "admin@email.com", senderName: cfg.senderName ?? "Admin", })), @@ -336,13 +365,14 @@ function resolvePgmetaConfig( input: PgmetaConfig | undefined, raw: PgmetaConfig | false | undefined, ports: PortSet, + versions: VersionResolutionOptions, ): Effect.Effect { if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; return requiredPort(ports, "pgmetaPort").pipe( Effect.map((port) => ({ port, - version: cfg.version ?? DEFAULT_VERSIONS.pgmeta, + version: resolveServiceVersion("pgmeta", cfg.version, versions), })), ); } @@ -352,13 +382,14 @@ function resolveStudioConfig( raw: StudioConfig | false | undefined, ports: PortSet, apiPort: number, + versions: VersionResolutionOptions, ): Effect.Effect { if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; return requiredPort(ports, "studioPort").pipe( Effect.map((port) => ({ port, - version: cfg.version ?? DEFAULT_VERSIONS.studio, + version: resolveServiceVersion("studio", cfg.version, versions), apiUrl: cfg.apiUrl ?? `http://127.0.0.1:${apiPort}`, })), ); @@ -368,13 +399,14 @@ function resolveAnalyticsConfig( input: AnalyticsConfig | undefined, raw: AnalyticsConfig | false | undefined, ports: PortSet, + versions: VersionResolutionOptions, ): Effect.Effect { if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; return requiredPort(ports, "analyticsPort").pipe( Effect.map((port) => ({ port, - version: cfg.version ?? DEFAULT_VERSIONS.analytics, + version: resolveServiceVersion("analytics", cfg.version, versions), backend: cfg.backend ?? "postgres", apiKey: cfg.apiKey ?? "api-key", })), @@ -384,30 +416,43 @@ function resolveAnalyticsConfig( function resolveVectorConfig( input: VectorConfig | undefined, raw: VectorConfig | false | undefined, + ports: PortSet, + mode: StackRuntimeSelection["mode"], + versions: VersionResolutionOptions, ): Effect.Effect { if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; - return Effect.succeed({ - version: cfg.version ?? DEFAULT_VERSIONS.vector, - }); + const resolved = { version: resolveServiceVersion("vector", cfg.version, versions) }; + return mode === "native" + ? requiredPort(ports, "vectorAdminPort").pipe( + Effect.map((adminPort) => ({ ...resolved, adminPort })), + ) + : Effect.succeed(resolved); } function resolvePoolerConfig( input: PoolerConfig | undefined, raw: PoolerConfig | false | undefined, ports: PortSet, + mode: StackRuntimeSelection["mode"], + versions: VersionResolutionOptions, ): Effect.Effect { if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; - return Effect.all({ - port: requiredPort(ports, "poolerPort"), + const publicPorts = Effect.all({ + sessionPort: requiredPort(ports, "poolerSessionPort"), + transactionPort: requiredPort(ports, "poolerTransactionPort"), apiPort: requiredPort(ports, "poolerApiPort"), - }).pipe( - Effect.map(({ port, apiPort }) => ({ - port, + }); + const internalPort = mode === "native" ? requiredPort(ports, "poolerInternalPort") : Effect.void; + return Effect.all({ publicPorts, internalPort }).pipe( + Effect.map(({ publicPorts: { sessionPort, transactionPort, apiPort }, internalPort }) => ({ + sessionPort, + transactionPort, apiPort, + ...(internalPort === undefined ? {} : { internalPort }), mode: cfg.mode ?? "transaction", - version: cfg.version ?? DEFAULT_VERSIONS.pooler, + version: resolveServiceVersion("pooler", cfg.version, versions), tenantId: cfg.tenantId ?? "pooler-dev", encryptionKey: cfg.encryptionKey ?? "12345678901234567890123456789032", secretKeyBase: @@ -432,11 +477,7 @@ export const rawServiceEnabled = (config: StackConfig, service: ServiceName): bo case "auth": return config.auth !== false; case "edge-runtime": - return ( - ((config.mode ?? "native") !== "native" || config.edgeRuntime !== undefined) && - config.edgeRuntime !== false && - (config.edgeRuntime?.enabled ?? true) !== false - ); + return config.edgeRuntime !== false && (config.edgeRuntime?.enabled ?? true) !== false; case "realtime": return config.realtime !== undefined && config.realtime !== false; case "storage": @@ -613,10 +654,16 @@ export const portRequestsForConfig = ( return studioInput?.port; case "analyticsPort": return analyticsInput?.port; - case "poolerPort": - return poolerInput?.port; + case "vectorAdminPort": + return undefined; + case "poolerSessionPort": + return poolerInput?.sessionPort; + case "poolerTransactionPort": + return poolerInput?.transactionPort; case "poolerApiPort": return poolerInput?.apiPort; + case "poolerInternalPort": + return undefined; case "postgrestPort": case "postgrestAdminPort": return undefined; @@ -666,6 +713,10 @@ export function resolveConfig( mode: "native", containerRuntime: null, }; + const versionResolution: VersionResolutionOptions = { + defaults: defaultVersionsForRuntime(runtime.mode), + runtime: runtime.mode, + }; const config: StackConfig = { ...inputConfig, mode: runtime.mode }; // Deliberately first: unsupported policies must not create roots or reserve ports. const servicePolicies = yield* resolveServicePolicies(config); @@ -753,50 +804,94 @@ export function resolveConfig( postgres: { port: dbPort, dataDir: postgresDataDir, - version: postgresInput.version ?? DEFAULT_VERSIONS.postgres, + version: resolveServiceVersion("postgres", postgresInput.version, versionResolution), autoExposeNewTables: postgresInput.autoExposeNewTables ?? true, }, postgrest: yield* resolvePostgrestConfig( postgrestInput, servicePolicies.postgrest === "off" ? false : config.postgrest, ports, + versionResolution, ), auth: yield* resolveAuthConfig( authInput, servicePolicies.auth === "off" ? false : config.auth, ports, apiPort, + versionResolution, ), edgeRuntime: edgeRuntimeEnabled - ? yield* resolveEdgeRuntimeConfig(edgeRuntimeInput, config.edgeRuntime, ports) + ? yield* resolveEdgeRuntimeConfig( + edgeRuntimeInput, + config.edgeRuntime, + ports, + versionResolution, + ) : false, realtime: realtimeEnabled - ? yield* resolveRealtimeConfig(realtimeInput, config.realtime, ports) + ? yield* resolveRealtimeConfig(realtimeInput, config.realtime, ports, versionResolution) : false, storage: storageEnabled - ? yield* resolveStorageConfig(storageInput, config.storage, ports, { - ...opts, - stackRoot: roots.stackRoot, - }) + ? yield* resolveStorageConfig( + storageInput, + config.storage, + ports, + { + ...opts, + stackRoot: roots.stackRoot, + }, + versionResolution, + ) : false, imgproxy: imgproxyEnabled - ? yield* resolveImgproxyConfig(imgproxyInput, config.imgproxy, ports) + ? yield* resolveImgproxyConfig(imgproxyInput, config.imgproxy, ports, versionResolution) : false, mailpit: mailpitEnabled - ? yield* resolveMailpitConfig(mailpitInput, config.mailpit, ports) + ? yield* resolveMailpitConfig( + mailpitInput, + config.mailpit, + ports, + roots.stackRoot, + versionResolution, + ) : false, pgmeta: pgmetaEnabled - ? yield* resolvePgmetaConfig(pgmetaInput, config.pgmeta, ports) + ? yield* resolvePgmetaConfig(pgmetaInput, config.pgmeta, ports, versionResolution) : false, studio: studioEnabled - ? yield* resolveStudioConfig(studioInput, config.studio, ports, apiPort) + ? yield* resolveStudioConfig( + studioInput, + config.studio, + ports, + apiPort, + versionResolution, + ) : false, analytics: analyticsEnabled - ? yield* resolveAnalyticsConfig(analyticsInput, config.analytics, ports) + ? yield* resolveAnalyticsConfig( + analyticsInput, + config.analytics, + ports, + versionResolution, + ) + : false, + vector: vectorEnabled + ? yield* resolveVectorConfig( + vectorInput, + config.vector, + ports, + runtime.mode, + versionResolution, + ) : false, - vector: vectorEnabled ? yield* resolveVectorConfig(vectorInput, config.vector) : false, pooler: poolerEnabled - ? yield* resolvePoolerConfig(poolerInput, config.pooler, ports) + ? yield* resolvePoolerConfig( + poolerInput, + config.pooler, + ports, + runtime.mode, + versionResolution, + ) : false, }; }).pipe( diff --git a/packages/stack/src/StackPreparation.ts b/packages/stack/src/StackPreparation.ts index 18da3672e5..868b643d93 100644 --- a/packages/stack/src/StackPreparation.ts +++ b/packages/stack/src/StackPreparation.ts @@ -10,10 +10,9 @@ import type { DownloadError, } from "./errors.ts"; import { BinaryNotFoundError, DockerPullError, isDockerDaemonDownMessage } from "./errors.ts"; -import { isDockerOnlyService, requiredPreparationDependencies } from "./ServiceCatalog.ts"; +import { requiredPreparationDependencies, SERVICE_NAMES } from "./ServiceCatalog.ts"; import { - DEFAULT_VERSIONS, - SERVICE_NAMES, + defaultVersionsForRuntime, dockerImageForService, normalizeServiceVersions, type ServiceName, @@ -115,16 +114,12 @@ export const preparationClosure = ( }; const selectedServices = (input: StackPreparationInput): ReadonlyArray => { - const defaults = - input.mode === "docker" - ? SERVICE_NAMES - : SERVICE_NAMES.filter((service) => !isDockerOnlyService(service)); - return preparationClosure(input.services ?? defaults, input.enabledServices); + return preparationClosure(input.services ?? SERVICE_NAMES, input.enabledServices); }; const versionsForInput = (input: StackPreparationInput): VersionManifest => ({ - ...DEFAULT_VERSIONS, - ...normalizeServiceVersions(input.versions ?? {}), + ...defaultVersionsForRuntime(input.mode), + ...normalizeServiceVersions(input.versions ?? {}, input.mode), }); const plannedResolution = ( @@ -139,9 +134,6 @@ const plannedResolution = ( image: dockerImageForService(service, version), }); } - if (isDockerOnlyService(service)) { - return Effect.fail(new BinaryNotFoundError({ service, platform: "native" })); - } return resolver .plan({ service, version }) .pipe(Effect.map((path): ServiceResolution => ({ type: "binary", path }))); diff --git a/packages/stack/src/SupervisorUpgradeRestart.integration.test.ts b/packages/stack/src/SupervisorUpgradeRestart.integration.test.ts index 74497cb00d..457bbefe19 100644 --- a/packages/stack/src/SupervisorUpgradeRestart.integration.test.ts +++ b/packages/stack/src/SupervisorUpgradeRestart.integration.test.ts @@ -14,18 +14,14 @@ import { } from "./managed/control.ts"; import type { ManagedStack, ManagedStackManagerShape } from "./managed/manager.ts"; import type { SupervisorStartMessage } from "./SupervisorProtocol.ts"; -import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; import type { ServiceName } from "./ServiceName.ts"; import { reservePortSet } from "./PortAllocator.ts"; import { prepareUpgradeReplacement } from "./SupervisorUpgradeRestart.ts"; import { StopTimeout, UpgradePreflightError, UpgradeRestartError } from "./errors.ts"; import type { DaemonConfigInput } from "./StackConfigResolver.ts"; -import { fillServiceVersionManifest } from "./versions.ts"; const roots: Array = []; -const allPersistedVersions = fillServiceVersionManifest({}); - afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -375,37 +371,6 @@ describe("incompatible supervisor upgrade restart", () => { }, ); - it.live("keeps restart-request exclusions from enabling native Docker-only services", () => { - const context = setup(allPersistedVersions); - const launch = context.input.launch ?? { versions: {} }; - const input = { - ...context.input, - launch: { ...launch, excludedServices: ["studio", "analytics"] }, - }; - return prepareUpgradeReplacement({ - ...context, - input, - configInput: context.configInput, - controlTransport: context.transport, - }).pipe( - Effect.provide(NodeServices.layer), - Effect.scoped, - Effect.tap((result) => - Effect.sync(() => { - for (const service of SERVICE_NAMES) { - if (SERVICE_CATALOG[service].runtimeSupport === "docker-only") { - const configKey = SERVICE_CATALOG[service].configKey; - expect(result.effectiveConfigInput[configKey]).toEqual( - context.configInput[configKey], - ); - } - } - }), - ), - Effect.asVoid, - ); - }); - it.effect("reports the refreshed owner state when stop times out", () => { const context = setup(); const stoppingStatus: ControlSupervisorStatus = { diff --git a/packages/stack/src/SupervisorUpgradeRestart.ts b/packages/stack/src/SupervisorUpgradeRestart.ts index 95523b1c9b..9cdf9f3010 100644 --- a/packages/stack/src/SupervisorUpgradeRestart.ts +++ b/packages/stack/src/SupervisorUpgradeRestart.ts @@ -13,7 +13,7 @@ import type { ManagedStackLaunch } from "./managed/document.ts"; import { PORT_CATALOG, PORT_FIELDS, type PortField } from "./PortCatalog.ts"; import { reservePortSet } from "./PortAllocator.ts"; import { portFieldsForConfigInput } from "./ServicePorts.ts"; -import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; +import { SERVICE_NAMES } from "./ServiceCatalog.ts"; import { expandExcludedServices } from "./ServiceExclusions.ts"; import { portRequestsForConfig, @@ -66,34 +66,17 @@ const persistedPortField = (key: string): PortField | undefined => { return "studioPort"; case "analytics.port": return "analyticsPort"; - case "db.pooler.port": - return "poolerPort"; + case "db.pooler.session_port": + return "poolerSessionPort"; + case "db.pooler.transaction_port": + return "poolerTransactionPort"; + case "db.pooler.api_port": + return "poolerApiPort"; default: return undefined; } }; -const isCatalogDefaultServiceConfig = (value: unknown): boolean => { - if (value === undefined) return true; - if (typeof value !== "object" || value === null) return false; - return Object.keys(value).every((key) => key === "version"); -}; - -export const applyNativeDefaults = (config: DaemonConfigInput): DaemonConfigInput => { - const servicePolicies = { ...config.servicePolicies }; - for (const service of SERVICE_NAMES) { - const metadata = SERVICE_CATALOG[service]; - if ( - metadata.runtimeSupport === "docker-only" && - servicePolicies[service] === undefined && - isCatalogDefaultServiceConfig(config[metadata.configKey]) - ) { - servicePolicies[service] = "off"; - } - } - return { ...config, servicePolicies }; -}; - const enableService = ( config: DaemonConfigInput, service: (typeof SERVICE_NAMES)[number], @@ -180,9 +163,7 @@ const applyPersistedLaunch = ( const persistedExclusions = expandExcludedServices(persisted.excludedServices ?? []); const servicesToEnable = new Set<(typeof SERVICE_NAMES)[number]>(); for (const service of restartRequestExclusions) { - if (persisted.mode !== "native" || SERVICE_CATALOG[service].runtimeSupport !== "docker-only") { - servicesToEnable.add(service); - } + servicesToEnable.add(service); } for (const service of SERVICE_NAMES) { if ( @@ -264,15 +245,11 @@ const preflight = ( existing.launch, context.input.launch, ); - const effectiveConfigInput = - persistedRuntime.mode === "native" && context.configInput.mode === undefined - ? applyNativeDefaults(withExclusions) - : withExclusions; - const portRequests = yield* portRequestsForConfig(effectiveConfigInput, { + const portRequests = yield* portRequestsForConfig(withExclusions, { runtime: persistedRuntime, }).pipe(Effect.mapError((cause) => preflightError(context, causeMessage(cause)))); const activeFields = portFieldsForConfigInput({ - ...effectiveConfigInput, + ...withExclusions, mode: persistedRuntime.mode, }); const activeFieldSet = new Set(activeFields); @@ -325,8 +302,8 @@ const preflight = ( } const resolvedConfig = yield* resolveConfig( { - ...effectiveConfigInput, - projectDir: effectiveConfigInput.projectDir ?? context.input.workspacePath, + ...withExclusions, + projectDir: withExclusions.projectDir ?? context.input.workspacePath, mode: persistedRuntime.mode, }, { @@ -337,7 +314,7 @@ const preflight = ( }, ).pipe(Effect.mapError((cause) => preflightError(context, causeMessage(cause)))); return { - effectiveConfigInput, + effectiveConfigInput: withExclusions, launch: { ...existing.launch, versions: { diff --git a/packages/stack/src/createStack.integration.test.ts b/packages/stack/src/createStack.integration.test.ts index 26113c6a9e..5895ea645c 100644 --- a/packages/stack/src/createStack.integration.test.ts +++ b/packages/stack/src/createStack.integration.test.ts @@ -35,8 +35,9 @@ const testPorts: PortSet = { pgmetaPort: 40_013, studioPort: 40_014, analyticsPort: 40_015, - poolerPort: 40_016, - poolerApiPort: 40_017, + poolerSessionPort: 40_016, + poolerTransactionPort: 40_017, + poolerApiPort: 40_018, }; afterEach(() => { @@ -240,6 +241,7 @@ describe("direct createStack port ownership", () => { ); handles.push(stack); const publicStack = toStackHandle(stack); + expect(publicStack.serviceEndpoints).toBe(stack.serviceEndpoints); const secondInvoked = Deferred.makeUnsafe(); const secondDone = Deferred.makeUnsafe(); @@ -261,4 +263,79 @@ describe("direct createStack port ownership", () => { expect(Exit.isSuccess(firstExit)).toBe(true); expect(Exit.isSuccess(secondExit)).toBe(true); }); + + it("resolves public disposal when runtime cleanup is interrupted", async () => { + const interruptedPlatformFactory: PlatformFactory = (options) => + Layer.mergeAll( + platformFactory(options), + Layer.effectDiscard(Effect.addFinalizer(() => Effect.failCause(Cause.interrupt(1)))), + ); + const stack = await Effect.runPromise( + createStack( + { + mode: "native", + postgrest: false, + auth: false, + edgeRuntime: false, + realtime: false, + storage: false, + imgproxy: false, + mailpit: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, + }, + interruptedPlatformFactory, + { mode: "native", containerRuntime: null }, + resolveConfig, + ).pipe(Effect.provide(NodeServices.layer)), + ); + handles.push(stack); + + await expect(toStackHandle(stack).dispose()).resolves.toBeUndefined(); + }); + + it("preserves non-interrupt disposal failures on the public surface", async () => { + const failedPlatformFactory: PlatformFactory = (options) => + Layer.mergeAll( + platformFactory(options), + Layer.effectDiscard( + Effect.addFinalizer(() => + Effect.failCause(Cause.die(new Error("runtime cleanup failed"))), + ), + ), + ); + const stack = await Effect.runPromise( + createStack( + { + mode: "native", + postgrest: false, + auth: false, + edgeRuntime: false, + realtime: false, + storage: false, + imgproxy: false, + mailpit: false, + pgmeta: false, + studio: false, + analytics: false, + vector: false, + pooler: false, + }, + failedPlatformFactory, + { mode: "native", containerRuntime: null }, + resolveConfig, + ).pipe(Effect.provide(NodeServices.layer)), + ); + + const disposalExit = await Effect.runPromiseExit( + Effect.promise(() => toStackHandle(stack).dispose()), + ); + expect(Exit.isFailure(disposalExit)).toBe(true); + if (Exit.isFailure(disposalExit)) { + expect(Cause.pretty(disposalExit.cause)).toContain("runtime cleanup failed"); + } + }); }); diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index 2d4755d462..2a97ba8336 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -68,6 +68,8 @@ type CreateStackAttemptError = export interface ForegroundStackHandle { readonly url: string; readonly dbUrl: string; + /** Published endpoints for configured services. */ + readonly serviceEndpoints: Readonly>; readonly publishableKey: string; readonly secretKey: string; readonly start: Effect.Effect; @@ -191,7 +193,9 @@ const createStackAttempt = ( let disposalStarted = false; const awaitDisposal = Deferred.await(disposalCompletion).pipe( Effect.flatMap((exit) => - Exit.isSuccess(exit) ? Effect.void : Effect.failCause(exit.cause), + Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) + ? Effect.void + : Effect.failCause(exit.cause), ), ); const dispose = Effect.uninterruptibleMask((restore) => @@ -232,6 +236,7 @@ const createStackAttempt = ( return { url: info.url, dbUrl: info.dbUrl, + serviceEndpoints: info.serviceEndpoints, publishableKey: info.publishableKey, secretKey: info.secretKey, start: run(localStack.start), diff --git a/packages/stack/src/createStack.unit.test.ts b/packages/stack/src/createStack.unit.test.ts index 9b21f0e9cc..6f5654569f 100644 --- a/packages/stack/src/createStack.unit.test.ts +++ b/packages/stack/src/createStack.unit.test.ts @@ -18,6 +18,7 @@ import { } from "./StackConfigResolver.ts"; import { DEFAULT_VERSIONS } from "./versions.ts"; import type { PortField, PortSet } from "./PortCatalog.ts"; +import { portFieldsForService } from "./ServicePorts.ts"; const testPorts = (config?: Parameters[0]): PortSet => { const ports = { @@ -37,8 +38,11 @@ const testPorts = (config?: Parameters[0]): PortSet pgmetaPort: 40_013, studioPort: 40_014, analyticsPort: 40_015, - poolerPort: 40_016, - poolerApiPort: 40_017, + vectorAdminPort: 40_018, + poolerSessionPort: 40_016, + poolerTransactionPort: 40_017, + poolerApiPort: 40_019, + poolerInternalPort: 40_020, } satisfies Record; return { ...ports, apiPort: config?.port ?? ports.apiPort }; }; @@ -167,11 +171,16 @@ describe("StackConfigResolver", () => { }); describe("resolveConfig edge runtime defaults", () => { - it("disables edge runtime when omitted in native mode", async () => { + it("enables edge runtime when omitted in native mode", async () => { const config = await resolveConfig({ mode: "native" }); expect(config.runtime).toEqual({ mode: "native", containerRuntime: null }); - expect(config.edgeRuntime).toBe(false); + expect(config.edgeRuntime).toEqual( + expect.objectContaining({ + enabled: true, + version: DEFAULT_VERSIONS["edge-runtime"], + }), + ); }); it("enables edge runtime when omitted in Docker mode", async () => { @@ -213,18 +222,6 @@ describe("resolveConfig edge runtime defaults", () => { }), ); }); - - it("preserves explicit edge runtime opt-in in native mode for builder validation", async () => { - const config = await resolveConfig({ mode: "native", edgeRuntime: {} }); - - expect(config.runtime).toEqual({ mode: "native", containerRuntime: null }); - expect(config.edgeRuntime).toEqual( - expect.objectContaining({ - enabled: true, - version: DEFAULT_VERSIONS["edge-runtime"], - }), - ); - }); }); describe("portRequestsForConfig explicit ports", () => { @@ -253,15 +250,21 @@ describe("portRequestsForConfig explicit ports", () => { edgeRuntime: false, postgrest: false, auth: false, - pooler: { port: 42423, apiPort: 42424 }, + pooler: { sessionPort: 42423, transactionPort: 42425, apiPort: 42424 }, }, { runtime: { mode: "docker", containerRuntime: "docker" }, - ports: { ...testPorts(), poolerPort: 42423, poolerApiPort: 42424 }, + ports: { + ...testPorts(), + poolerSessionPort: 42423, + poolerTransactionPort: 42425, + poolerApiPort: 42424, + }, }, ); - expect(config.ports.poolerPort).toBe(42423); + expect(config.ports.poolerSessionPort).toBe(42423); + expect(config.ports.poolerTransactionPort).toBe(42425); expect(config.ports.poolerApiPort).toBe(42424); }); @@ -287,6 +290,25 @@ describe("portRequestsForConfig explicit ports", () => { }); }); +describe("native Vector private port allocation", () => { + it("leases Vector's private health port only for native mode", async () => { + const nativeFields = await Effect.runPromise( + portRequestsForConfig({ mode: "native", analytics: {}, vector: {} }), + ); + expect(nativeFields.map((request) => request.field)).toContain("vectorAdminPort"); + + const dockerFields = await Effect.runPromise( + portRequestsForConfig( + { mode: "docker", analytics: {}, vector: {} }, + { runtime: { mode: "docker", containerRuntime: "docker" } }, + ), + ); + expect(dockerFields.map((request) => request.field)).not.toContain("vectorAdminPort"); + expect(portFieldsForService("vector", "native")).toContain("vectorAdminPort"); + expect(portFieldsForService("vector", "docker")).not.toContain("vectorAdminPort"); + }); +}); + describe("candidateCleanupTargets", () => { it("derives fallback Docker identities from enabled catalog services", async () => { const config = await resolveConfig( diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index b860961ad0..c4572b567e 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -33,6 +33,8 @@ export { detectPlatform, nativeTargetForPlatform } from "./Platform.ts"; export { expandExcludedServices } from "./ServiceExclusions.ts"; +export { nativeLogRoot, nativeServiceLogPath } from "./NativeLogWriter.ts"; + export type { ContainerRuntime, StackRuntimeSelection } from "./ContainerRuntime.ts"; export { selectStackRuntime, validateStackRuntime } from "./ContainerRuntime.ts"; @@ -135,9 +137,16 @@ export { resolveFunctionsRuntimeConfig, } from "./functions.ts"; -export type { AvailableServiceVersionUpdate, ServiceName, VersionManifest } from "./versions.ts"; +export type { + AvailableServiceVersionUpdate, + ServiceName, + VersionManifest, + VersionRuntime, +} from "./versions.ts"; export { DEFAULT_VERSIONS, + DOCKER_DEFAULT_VERSIONS, + defaultVersionsForRuntime, diffPinnedAndAvailableVersions, dockerImageForService, fillServiceVersionManifest, diff --git a/packages/stack/src/functions.ts b/packages/stack/src/functions.ts index 9b29c7051f..b407cbee8f 100644 --- a/packages/stack/src/functions.ts +++ b/packages/stack/src/functions.ts @@ -162,6 +162,15 @@ function edgeRuntimeWorkspaceDir(runtimeRoot: string): string { return join(runtimeRoot, "edge-runtime"); } +const runtimePathFor = ( + runtimeRoot: string, + value: string, + mode: ResolvedStackConfig["runtime"]["mode"], +): string => + mode === "native" + ? relative(edgeRuntimeWorkspaceDir(runtimeRoot), value).replaceAll(sep, "/") + : value; + export function functionsRuntimeConfigPath(runtimeRoot: string): string { return join(edgeRuntimeWorkspaceDir(runtimeRoot), functionsRuntimeConfigFileName); } @@ -175,6 +184,9 @@ export function resolveFunctionsRuntimeConfig( return undefined; } + const runtimePath = (value: string) => + runtimePathFor(stackConfig.runtimeRoot, value, stackConfig.runtime.mode); + return { functionsUrl: `http://127.0.0.1:${stackConfig.apiPort}/functions/v1`, supabaseUrl: `http://${runtimeHost.hostname}:${stackConfig.apiPort}`, @@ -188,9 +200,9 @@ export function resolveFunctionsRuntimeConfig( fn.name, { verifyJWT: fn.verifyJWT, - entrypointPath: fn.entrypointPath, - importMapPath: fn.importMapPath, - staticFiles: fn.staticFiles, + entrypointPath: runtimePath(fn.entrypointPath), + importMapPath: fn.importMapPath === null ? null : runtimePath(fn.importMapPath), + staticFiles: fn.staticFiles.map(runtimePath), env: fn.env, }, ]), @@ -200,7 +212,7 @@ export function resolveFunctionsRuntimeConfig( const writeFunctionsRuntimeConfig = Effect.fnUntraced(function* ( runtimeRoot: string, - config: FunctionsRuntimeConfig, + config: FunctionsRuntimeConfig | null, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -245,7 +257,7 @@ export const configureFunctionsRuntime = Effect.fnUntraced(function* ( ) { const runtimeConfig = resolveFunctionsRuntimeConfig(stackConfig, runtimeHost, bundle); if (runtimeConfig === undefined) { - yield* clearFunctionsRuntimeConfig(stackConfig.runtimeRoot); + yield* writeFunctionsRuntimeConfig(stackConfig.runtimeRoot, null); } else { yield* writeFunctionsRuntimeConfig(stackConfig.runtimeRoot, runtimeConfig); } diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index d28c0259da..87f1726367 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -21,7 +21,7 @@ import { resolveFunctionsRuntimeConfig, type ResolvedFunctionsBundle, } from "./functions.ts"; -import { verifyRequest } from "./services/edge-runtime-main.ts"; +import { fileUrl, verifyRequest } from "./services/edge-runtime-main.ts"; const testPorts: PortSet = { apiPort: 40_000, @@ -40,8 +40,9 @@ const testPorts: PortSet = { pgmetaPort: 40_013, studioPort: 40_014, analyticsPort: 40_015, - poolerPort: 40_016, - poolerApiPort: 40_017, + poolerSessionPort: 40_016, + poolerTransactionPort: 40_017, + poolerApiPort: 40_018, }; const resolveConfig = ( @@ -147,12 +148,46 @@ describe("stack Functions runtime config", () => { await rm(root, { recursive: true, force: true }); }); + it("projects native function paths relative to the Edge Runtime workspace", async () => { + const root = makeTempProject(); + const runtimeRoot = join(root, "runtime"); + const stackConfig = await resolveConfig( + { projectDir: root, runtimeRoot, functions: makeBundle(root) }, + { runtime: { mode: "native", containerRuntime: null } }, + ); + const config = resolveFunctionsRuntimeConfig( + stackConfig, + { hostname: "127.0.0.1" }, + makeBundle(root), + ); + + expect(config?.functions["hello-world"]).toEqual({ + verifyJWT: true, + entrypointPath: "../../functions/hello-world/index.ts", + importMapPath: null, + staticFiles: ["../../functions/hello-world/assets/*"], + env: { SHARED: "function-value", FUNCTION_ONLY: "function-value" }, + }); + + await rm(root, { recursive: true, force: true }); + }); + + it("resolves relative native entrypoints against the runtime cwd", () => { + expect(fileUrl("../../functions/hello-world/index.ts", "/tmp/runtime/edge-runtime")).toBe( + "file:///tmp/functions/hello-world/index.ts", + ); + expect(fileUrl("/tmp/functions/hello-world/index.ts")).toBe( + "file:///tmp/functions/hello-world/index.ts", + ); + }); + it("rejects a function bundle when Edge Runtime is disabled", async () => { const root = makeTempProject(); const error = await resolveConfig({ mode: "native", projectDir: root, + edgeRuntime: false, functions: makeBundle(root), }).then( () => undefined, @@ -284,8 +319,12 @@ describe("stack Functions runtime config", () => { { runtime: { mode: "docker", containerRuntime: "docker" } }, ), ); - yield* configureFunctionsRuntime(stackConfig, { hostname: "127.0.0.1" }, bundle); + yield* configureFunctionsRuntime(stackConfig, { hostname: "127.0.0.1" }, undefined); const filePath = functionsRuntimeConfigPath(stackConfig.runtimeRoot); + expect(yield* Effect.promise(() => readFile(filePath, "utf8"))).toBe("null\n"); + expect((yield* Effect.promise(() => stat(filePath))).mode & 0o777).toBe(0o600); + + yield* configureFunctionsRuntime(stackConfig, { hostname: "127.0.0.1" }, bundle); const written = JSON.parse(yield* Effect.promise(() => readFile(filePath, "utf8"))) as { functions: Record; }; diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 5d7516db5e..9bb7e4783e 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -30,6 +30,7 @@ export type { ServiceResolution, StackPreparationError } from "./StackPreparatio export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; export type { StackHandle } from "./stackHandle.ts"; export { StackError } from "./errors.ts"; +export { nativeLogRoot, nativeServiceLogPath } from "./NativeLogWriter.ts"; export type { FunctionsReloadConfig, FunctionsRuntimeConfig, diff --git a/packages/stack/src/layers.ts b/packages/stack/src/layers.ts index 3455f4e457..45c9df4ac4 100644 --- a/packages/stack/src/layers.ts +++ b/packages/stack/src/layers.ts @@ -83,6 +83,7 @@ const runtimeBootLayer = ( postgrestAdminPort: config.postgrest !== false ? config.postgrest.adminPort : 0, edgeRuntimePort: config.edgeRuntime !== false ? config.edgeRuntime.port : 0, realtimePort: config.realtime !== false ? config.realtime.port : 0, + realtimeTenantId: config.realtime !== false ? config.realtime.tenantId : "realtime-dev", storagePort: config.storage !== false ? config.storage.port : 0, pgmetaPort: config.pgmeta !== false ? config.pgmeta.port : 0, analyticsPort: config.analytics !== false ? config.analytics.port : 0, diff --git a/packages/stack/src/managed/document.ts b/packages/stack/src/managed/document.ts index a0d7997cf2..67756bfc99 100644 --- a/packages/stack/src/managed/document.ts +++ b/packages/stack/src/managed/document.ts @@ -81,7 +81,9 @@ const managedPortAssignmentSchema = Schema.Struct({ "local_smtp.pop3_port", "studio.port", "analytics.port", - "db.pooler.port", + "db.pooler.session_port", + "db.pooler.transaction_port", + "db.pooler.api_port", ]), port: Schema.Finite, intent: Schema.Literals(["automatic", "exact"]), diff --git a/packages/stack/src/native-filesystem.ts b/packages/stack/src/native-filesystem.ts new file mode 100644 index 0000000000..5fd26334cc --- /dev/null +++ b/packages/stack/src/native-filesystem.ts @@ -0,0 +1,30 @@ +import { Effect, FileSystem } from "effect"; +import { StackBuildError } from "./errors.ts"; + +/** Create a private native runtime directory, preserving its typed build error. */ +export const prepareNativeDirectory = ( + path: string, + detail: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs + .makeDirectory(path, { recursive: true, mode: 0o700 }) + .pipe(Effect.mapError((cause) => new StackBuildError({ detail, cause }))); + }); + +type WriteFileOptions = NonNullable[2]>; + +/** Write a private native text file, preserving its typed build error. */ +export const writeNativeFile = ( + path: string, + content: string, + detail: string, + options?: WriteFileOptions, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs + .writeFileString(path, content, options) + .pipe(Effect.mapError((cause) => new StackBuildError({ detail, cause }))); + }); diff --git a/packages/stack/src/prefetch.unit.test.ts b/packages/stack/src/prefetch.unit.test.ts index e98412f126..624238bdc5 100644 --- a/packages/stack/src/prefetch.unit.test.ts +++ b/packages/stack/src/prefetch.unit.test.ts @@ -24,7 +24,12 @@ import { PreparationCompleted, StackPreparation, } from "./StackPreparation.ts"; -import { DEFAULT_VERSIONS, SERVICE_NAMES, dockerImageForService } from "./versions.ts"; +import { + DEFAULT_VERSIONS, + DOCKER_DEFAULT_VERSIONS, + SERVICE_NAMES, + dockerImageForService, +} from "./versions.ts"; const encoder = new TextEncoder(); const defaultAuthGhcrImage = `ghcr.io/supabase/cli/auth:${DEFAULT_VERSIONS.auth}`; @@ -84,7 +89,11 @@ function mockSequenceSpawner(results: ReadonlyArray) { describe("prefetch", () => { test("prefetches every native-capable service by default in native mode", async () => { - const resolver = mockBinaryResolver(); + const resolver = mockBinaryResolver({ + binaries: Object.fromEntries( + SERVICE_NAMES.map((service) => [service, `/cache/${service}/native`]), + ), + }); const spawner = mockSequenceSpawner( Array.from({ length: SERVICE_NAMES.length }, () => ({ exitCode: 0, @@ -98,7 +107,88 @@ describe("prefetch", () => { const result = await Effect.runPromise(prefetch().pipe(Effect.provide(layer))); - expect(Object.keys(result).sort()).toEqual(["auth", "postgres", "postgrest"]); + expect(Object.keys(result).sort()).toEqual([...SERVICE_NAMES].sort()); + expect(resolver.resolved).toEqual( + expect.arrayContaining([ + { service: "edge-runtime", version: "v1.74.3" }, + { service: "realtime", version: "v2.129.1" }, + { service: "storage", version: "v1.70.1" }, + { service: "imgproxy", version: "v3.8.0" }, + { service: "mailpit", version: "v1.30.2" }, + { service: "pgmeta", version: "v0.98.0" }, + { service: "studio", version: "2026.08.17-sha-0c1da8f" }, + { service: "analytics", version: "v1.50.3" }, + { service: "vector", version: "0.53.0" }, + { service: "pooler", version: "v2.9.10" }, + ]), + ); + }); + + test("native preparation uses frozen remaining-service defaults and dependency closures", async () => { + const resolver = mockBinaryResolver({ + binaries: Object.fromEntries( + SERVICE_NAMES.map((service) => [service, `/cache/${service}/native`]), + ), + }); + const spawner = mockSequenceSpawner([]); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + + const result = await Effect.runPromise( + prefetch({ mode: "native", services: ["storage", "vector"] }).pipe(Effect.provide(layer)), + ); + + expect(Object.keys(result).sort()).toEqual([ + "analytics", + "imgproxy", + "postgres", + "storage", + "vector", + ]); + expect(resolver.resolved).toEqual( + expect.arrayContaining([ + { service: "storage", version: "v1.70.1" }, + { service: "imgproxy", version: "v3.8.0" }, + { service: "analytics", version: "v1.50.3" }, + { service: "vector", version: "0.53.0" }, + ]), + ); + }); + + test("Docker preparation uses valid Vector and Pooler image defaults", async () => { + const resolver = mockBinaryResolver(); + const spawner = mockSequenceSpawner(Array.from({ length: 5 }, () => ({ exitCode: 0 }))); + const layer = StackPreparation.layer.pipe( + Layer.provide(resolver.layer), + Layer.provide(spawner.layer), + ); + + const result = await Effect.runPromise( + prefetch({ + mode: "docker", + containerRuntime: "docker", + services: ["vector", "pooler"], + }).pipe(Effect.provide(layer)), + ); + + expect(result.vector).toEqual({ + type: "docker", + image: "ghcr.io/supabase/vector:0.53.0-alpine", + }); + expect(result.pooler).toEqual({ + type: "docker", + image: "ghcr.io/supabase/supavisor:2.9.12", + }); + expect(spawner.spawned).toContainEqual({ + command: "docker", + args: ["image", "inspect", dockerImageForService("vector", DOCKER_DEFAULT_VERSIONS.vector)], + }); + expect(spawner.spawned).toContainEqual({ + command: "docker", + args: ["image", "inspect", dockerImageForService("pooler", DOCKER_DEFAULT_VERSIONS.pooler)], + }); }); test("limits concurrent image preparation to four services", async () => { @@ -265,7 +355,11 @@ describe("prefetch", () => { expect(spawner.spawned.every(({ command }) => command === containerRuntime)).toBe(true); expect(spawner.spawned).toContainEqual({ command: containerRuntime, - args: ["image", "inspect", dockerImageForService(service, DEFAULT_VERSIONS[service])], + args: [ + "image", + "inspect", + dockerImageForService(service, DOCKER_DEFAULT_VERSIONS[service]), + ], }); }, ); @@ -357,8 +451,8 @@ describe("prefetch", () => { expect(resolver.resolved).toContainEqual({ service: "postgrest", version: "v16.1" }); }); - test("native mode rejects services that have no native runtime", async () => { - const resolver = mockBinaryResolver(); + test("native mode rejects services whose native artifact cannot be resolved", async () => { + const resolver = mockBinaryResolver({ failServices: ["edge-runtime"] }); const spawner = mockSequenceSpawner([]); const layer = StackPreparation.layer.pipe( Layer.provide(resolver.layer), @@ -410,7 +504,7 @@ describe("prefetch", () => { expect(result.pgmeta).toEqual({ type: "docker", - image: dockerImageForService("pgmeta", DEFAULT_VERSIONS.pgmeta), + image: dockerImageForService("pgmeta", DOCKER_DEFAULT_VERSIONS.pgmeta), }); }); diff --git a/packages/stack/src/services/analytics.ts b/packages/stack/src/services/analytics.ts index 6f4bcfcf03..a63be7fa62 100644 --- a/packages/stack/src/services/analytics.ts +++ b/packages/stack/src/services/analytics.ts @@ -1,27 +1,87 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- Native crash dumps are rooted in the caller-owned runtime directory. +import { join } from "node:path"; import type { ServiceDef } from "@supabase/process-compose"; import { dockerPortMapArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; import { dockerRunService, + nativeBeamLoopbackEnv, + nativeRunService, type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerAnalyticsOptions extends ContainerRuntimeOptions { - readonly image: string; - readonly identity: StackIdentity; +interface AnalyticsServiceOptions { readonly hostPort: number; - readonly platformOs: string; - readonly dbHost: string; readonly dbPort: number; readonly apiKey: string; readonly backend: "postgres" | "bigquery"; readonly dependencies: ReadonlyArray; } +export interface NativeAnalyticsOptions extends AnalyticsServiceOptions { + readonly binPath: string; + readonly runtimeRoot: string; + /** A stack-unique, valid BEAM short node name for Logflare distribution. */ + readonly nodeName: string; + /** A stack-owned secret shared by the native BEAM services. */ + readonly releaseCookie: string; +} + +export interface NativeAnalyticsServiceBundle { + /** Runs the bundled Logflare migration once before the server starts. */ + readonly migrate: ServiceDef; + /** Creates the local single-tenant Logflare records after migrations complete. */ + readonly seed: ServiceDef; + /** The public, long-running Logflare server. */ + readonly server: ServiceDef; +} + +interface DockerAnalyticsOptions extends AnalyticsServiceOptions, ContainerRuntimeOptions { + readonly image: string; + readonly identity: StackIdentity; + readonly platformOs: string; + readonly dbHost: string; +} + const ANALYTICS_CONTAINER_PORT = 4000; +/** Starts Logflare and waits for its single-tenant startup task to finish. */ +const ANALYTICS_SEED_SCRIPT = `{:ok, _} = Application.ensure_all_started(:logflare) +startup_task = + Supervisor.which_children(Logflare.Supervisor) + |> Enum.find(fn + {Task, pid, :worker, _modules} when is_pid(pid) -> true + _ -> false + end) + +case startup_task do + {Task, pid, :worker, _modules} -> + ref = Process.monitor(pid) + + receive do + {:DOWN, ^ref, :process, ^pid, :normal} -> :ok + {:DOWN, ^ref, :process, ^pid, reason} -> + raise "Logflare startup task failed: #{inspect(reason)}" + after + 120_000 -> + Process.demonitor(ref, [:flush]) + raise "Timed out waiting for Logflare startup task" + end + + nil -> + :ok +end + +status = Logflare.SingleTenant.supabase_mode_status() + +if status |> Map.values() |> Enum.all?(&(&1 == :ok)) do + :ok +else + raise "Logflare single-tenant bootstrap incomplete: #{inspect(status)}" +end`; + export const analyticsDockerRuntimeNetwork = ( _os: string, _hostPort: number, @@ -42,35 +102,62 @@ const analyticsHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ ...stackHealthBudgets.analytics, }); +const analyticsEnv = ( + opts: AnalyticsServiceOptions & { + readonly dbHost: string; + readonly listenPort: number; + readonly nodeHost: "127.0.0.1" | "0.0.0.0"; + readonly runtimeRoot?: string; + readonly releaseCookie?: string; + }, +): Record => ({ + PORT: String(opts.listenPort), + PHX_HTTP_PORT: String(opts.listenPort), + PHX_HTTP_IP: opts.nodeHost, + DB_DATABASE: "_supabase", + DB_HOSTNAME: opts.dbHost, + DB_PORT: String(opts.dbPort), + DB_SCHEMA: "_analytics", + DB_USERNAME: "postgres", + DB_PASSWORD: "postgres", + LOGFLARE_MIN_CLUSTER_SIZE: "1", + LOGFLARE_SINGLE_TENANT: "true", + LOGFLARE_SUPABASE_MODE: "true", + LOGFLARE_PUBLIC_ACCESS_TOKEN: `${opts.apiKey}-public`, + LOGFLARE_PRIVATE_ACCESS_TOKEN: opts.apiKey, + LOGFLARE_LOG_LEVEL: "warn", + LOGFLARE_NODE_HOST: opts.nodeHost, + LOGFLARE_FEATURE_FLAG_OVERRIDE: "'multibackend=true'", + RELEASE_COOKIE: opts.releaseCookie ?? "cookie", + ...(opts.backend === "postgres" + ? { + POSTGRES_BACKEND_URL: `postgresql://postgres:postgres@${opts.dbHost}:${opts.dbPort}/_supabase`, + POSTGRES_BACKEND_SCHEMA: "_analytics", + } + : { + GOOGLE_DATASET_ID_APPEND: "_prod", + GOOGLE_PROJECT_ID: "local", + GOOGLE_PROJECT_NUMBER: "0", + }), + ...(opts.runtimeRoot === undefined + ? {} + : { + ...nativeBeamLoopbackEnv, + ELIXIR_ERL_OPTIONS: "+S 1:1 +SDio 1 +sbwt none +sbwtdcpu none +sbwtdio none", + DB_POOL_SIZE: "2", + LOGFLARE_PUBSUB_POOL_SIZE: "2", + ERL_CRASH_DUMP: join(opts.runtimeRoot, "analytics", "erl_crash.dump"), + }), +}); + export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): ServiceDef => { const runtimeNetwork = analyticsDockerRuntimeNetwork(opts.platformOs, opts.hostPort, opts.dbHost); - const env: Record = { - PORT: String(runtimeNetwork.listenPort), - PHX_HTTP_PORT: String(runtimeNetwork.listenPort), - DB_DATABASE: "_supabase", - DB_HOSTNAME: opts.dbHost, - DB_PORT: String(opts.dbPort), - DB_SCHEMA: "_analytics", - DB_USERNAME: "postgres", - DB_PASSWORD: "postgres", - LOGFLARE_MIN_CLUSTER_SIZE: "1", - LOGFLARE_SINGLE_TENANT: "true", - LOGFLARE_SUPABASE_MODE: "true", - LOGFLARE_PRIVATE_ACCESS_TOKEN: opts.apiKey, - LOGFLARE_LOG_LEVEL: "warn", - LOGFLARE_NODE_HOST: runtimeNetwork.nodeHost, - LOGFLARE_FEATURE_FLAG_OVERRIDE: "'multibackend=true'", - RELEASE_COOKIE: "cookie", - }; - - if (opts.backend === "postgres") { - env.POSTGRES_BACKEND_URL = `postgresql://postgres:postgres@${opts.dbHost}:${opts.dbPort}/_supabase`; - env.POSTGRES_BACKEND_SCHEMA = "_analytics"; - } else { - env.GOOGLE_DATASET_ID_APPEND = "_prod"; - env.GOOGLE_PROJECT_ID = "local"; - env.GOOGLE_PROJECT_NUMBER = "0"; - } + const env = analyticsEnv({ + ...opts, + listenPort: runtimeNetwork.listenPort, + nodeHost: "0.0.0.0", + runtimeRoot: undefined, + }); return dockerRunService({ runtime: opts.runtime, @@ -85,3 +172,48 @@ export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): Servic healthCheck: analyticsHealthCheck(opts.hostPort), }); }; + +export const makeAnalyticsServicesNative = ( + opts: NativeAnalyticsOptions, +): NativeAnalyticsServiceBundle => { + const env = analyticsEnv({ + ...opts, + dbHost: "127.0.0.1", + listenPort: opts.hostPort, + nodeHost: "127.0.0.1", + runtimeRoot: opts.runtimeRoot, + }); + const migrate = nativeRunService({ + name: "analytics-migrate", + command: `${opts.binPath}/bin/logflare`, + args: ["eval", "Logflare.Release.migrate"], + env, + dependencies: opts.dependencies, + restart: "no", + }); + const seed = nativeRunService({ + name: "analytics-seed", + command: `${opts.binPath}/bin/logflare`, + args: ["eval", ANALYTICS_SEED_SCRIPT], + // The bootstrap starts Logflare to run its startup task but must not claim + // the public server port before the long-running process starts. + env: { + ...env, + PORT: "0", + PHX_HTTP_PORT: "0", + LOGFLARE_SINGLE_TENANT: "true", + LOGFLARE_SUPABASE_MODE: "true", + }, + dependencies: [{ service: migrate.name, condition: "completed" }], + restart: "no", + }); + const server = nativeRunService({ + name: "analytics", + command: `${opts.binPath}/bin/logflare`, + args: ["start", "--sname", opts.nodeName], + env, + dependencies: [{ service: seed.name, condition: "completed" }], + healthCheck: analyticsHealthCheck(opts.hostPort), + }); + return { migrate, seed, server }; +}; diff --git a/packages/stack/src/services/analytics.unit.test.ts b/packages/stack/src/services/analytics.unit.test.ts new file mode 100644 index 0000000000..aa5a1cc652 --- /dev/null +++ b/packages/stack/src/services/analytics.unit.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { makeAnalyticsServicesNative } from "./analytics.ts"; + +describe("makeAnalyticsServicesNative", () => { + it("runs migration before a loopback server with the frozen runtime profile", () => { + const dependencies = [{ service: "postgres-init", condition: "completed" }] as const; + const bundle = makeAnalyticsServicesNative({ + binPath: "/cache/analytics/v1.50.3/darwin-arm64", + runtimeRoot: "/tmp/stacks/project-a/runtime", + nodeName: "logflare_stack_a", + releaseCookie: "stack-release-cookie", + hostPort: 54327, + dbPort: 54322, + apiKey: "analytics-key", + backend: "postgres", + dependencies, + }); + + expect(bundle.migrate).toMatchObject({ + name: "analytics-migrate", + command: "/cache/analytics/v1.50.3/darwin-arm64/bin/logflare", + args: ["eval", "Logflare.Release.migrate"], + restart: "no", + dependencies, + }); + expect(bundle.seed).toMatchObject({ + name: "analytics-seed", + command: "/cache/analytics/v1.50.3/darwin-arm64/bin/logflare", + args: [ + "eval", + `{:ok, _} = Application.ensure_all_started(:logflare) +startup_task = + Supervisor.which_children(Logflare.Supervisor) + |> Enum.find(fn + {Task, pid, :worker, _modules} when is_pid(pid) -> true + _ -> false + end) + +case startup_task do + {Task, pid, :worker, _modules} -> + ref = Process.monitor(pid) + + receive do + {:DOWN, ^ref, :process, ^pid, :normal} -> :ok + {:DOWN, ^ref, :process, ^pid, reason} -> + raise "Logflare startup task failed: #{inspect(reason)}" + after + 120_000 -> + Process.demonitor(ref, [:flush]) + raise "Timed out waiting for Logflare startup task" + end + + nil -> + :ok +end + +status = Logflare.SingleTenant.supabase_mode_status() + +if status |> Map.values() |> Enum.all?(&(&1 == :ok)) do + :ok +else + raise "Logflare single-tenant bootstrap incomplete: #{inspect(status)}" +end`, + ], + restart: "no", + dependencies: [{ service: "analytics-migrate", condition: "completed" }], + }); + expect(bundle.seed.env).toMatchObject({ + PORT: "0", + PHX_HTTP_PORT: "0", + PHX_HTTP_IP: "127.0.0.1", + LOGFLARE_SINGLE_TENANT: "true", + LOGFLARE_SUPABASE_MODE: "true", + LOGFLARE_PUBLIC_ACCESS_TOKEN: "analytics-key-public", + LOGFLARE_PRIVATE_ACCESS_TOKEN: "analytics-key", + }); + expect(bundle.server).toMatchObject({ + name: "analytics", + command: "/cache/analytics/v1.50.3/darwin-arm64/bin/logflare", + args: ["start", "--sname", "logflare_stack_a"], + restart: "unless-stopped", + dependencies: [{ service: "analytics-seed", condition: "completed" }], + }); + expect(bundle.server.env).toMatchObject({ + PORT: "54327", + PHX_HTTP_PORT: "54327", + PHX_HTTP_IP: "127.0.0.1", + DB_HOSTNAME: "127.0.0.1", + LOGFLARE_NODE_HOST: "127.0.0.1", + DB_POOL_SIZE: "2", + LOGFLARE_PUBSUB_POOL_SIZE: "2", + ELIXIR_ERL_OPTIONS: "+S 1:1 +SDio 1 +sbwt none +sbwtdcpu none +sbwtdio none", + ERL_AFLAGS: "-proto_dist inet_tcp -kernel inet_dist_use_interface '{127,0,0,1}'", + ERL_EPMD_ADDRESS: "127.0.0.1", + ERL_CRASH_DUMP: "/tmp/stacks/project-a/runtime/analytics/erl_crash.dump", + RELEASE_COOKIE: "stack-release-cookie", + LOGFLARE_PUBLIC_ACCESS_TOKEN: "analytics-key-public", + LOGFLARE_PRIVATE_ACCESS_TOKEN: "analytics-key", + }); + expect(bundle.server.healthCheck?.probe).toEqual({ + _tag: "Http", + host: "127.0.0.1", + port: 54327, + path: "/health", + scheme: "http", + }); + }); +}); diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index 0075dc0ef4..a945dbc207 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -21,21 +21,6 @@ interface AuthFailure { message?: string; } -const configPath = - typeof Deno === "undefined" - ? new URL("./functions-runtime-config.json", import.meta.url) - : (Deno.env.get("FUNCTIONS_RUNTIME_CONFIG_PATH") ?? - new URL("./functions-runtime-config.json", import.meta.url)); - -async function loadConfig() { - try { - return JSON.parse(await Deno.readTextFile(configPath)); - } catch (error) { - console.error(`Failed to load Edge Functions runtime config from ${configPath}`, error); - return null; - } -} - function base64UrlToBytes(value: string) { const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); @@ -169,8 +154,14 @@ function dirname(path: string) { return index === -1 ? "." : path.slice(0, index); } -function fileUrl(path: string) { - return new URL(`file://${path}`).href; +/** Resolve a generated function path against the native Edge Runtime cwd. */ +export function fileUrl(path: string, cwd?: string) { + const absolutePath = path.startsWith("/") + ? path + : `${(cwd ?? (typeof Deno === "undefined" ? "" : Deno.cwd())).replace(/\/+$/, "")}/${path}`; + const url = new URL("file:///"); + url.pathname = absolutePath.replace(/%/g, "%25").replace(/\\/g, "%5C"); + return url.href; } async function serveFunction(req: Request, config: any, functionName: string, functionConfig: any) { @@ -219,7 +210,7 @@ async function serveFunction(req: Request, config: any, functionName: string, fu } } -if (typeof Deno !== "undefined") { +export function start(config: any) { Deno.serve({ handler: async (req: Request) => { const url = new URL(req.url); @@ -228,7 +219,6 @@ if (typeof Deno !== "undefined") { return Response.json({ message: "ok" }); } - const config = await loadConfig(); if (!config) return Response.json(placeholder, { status: 501 }); const functionName = url.pathname.split("/").filter(Boolean)[0]; @@ -240,7 +230,6 @@ if (typeof Deno !== "undefined") { return serveFunction(req, config, functionName, functionConfig); }, onListen: async () => { - const config = await loadConfig(); if (!config) return; const names = Object.keys(config.functions); const examples = names diff --git a/packages/stack/src/services/edge-runtime.ts b/packages/stack/src/services/edge-runtime.ts index 4986896803..ecb031258d 100644 --- a/packages/stack/src/services/edge-runtime.ts +++ b/packages/stack/src/services/edge-runtime.ts @@ -10,15 +10,15 @@ import { dockerRunService, hostUserForLinuxDocker, hostHttpHealthCheck, + nativeRunService, type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; import bootstrapSource from "./edge-runtime-main.ts" with { type: "text" }; import { stackHealthBudgets } from "./health-budgets.ts"; -import { edgeRuntimeNofileUlimit } from "./nofile-limit.ts"; +import { edgeRuntimeNofileSoftLimit, edgeRuntimeNofileUlimit } from "./nofile-limit.ts"; interface EdgeRuntimeOptions { - readonly runtimeRoot: string; readonly projectDir?: string; readonly port: number; readonly inspectorPort: number; @@ -27,6 +27,12 @@ interface EdgeRuntimeOptions { readonly dependencies: ReadonlyArray; } +export interface NativeEdgeRuntimeOptions extends Omit { + readonly binPath: string; + readonly bootstrapDir: string; + readonly projectDir: string; +} + interface DockerEdgeRuntimeOptions extends EdgeRuntimeOptions, ContainerRuntimeOptions { readonly image: string; readonly identity: StackIdentity; @@ -65,7 +71,12 @@ export const prepareEdgeRuntimeBootstrap = ( ), ) : bootstrapSource; - yield* fs.writeFileString(join(bootstrapDir, bootstrapFileName), source).pipe( + const generatedSource = [ + 'import functionsRuntimeConfig from "./functions-runtime-config.json" with { type: "json" };', + source, + "start(functionsRuntimeConfig);", + ].join("\n"); + yield* fs.writeFileString(join(bootstrapDir, bootstrapFileName), generatedSource).pipe( Effect.mapError( (cause) => new StackBuildError({ @@ -81,11 +92,6 @@ const edgeRuntimeEnv = (opts: EdgeRuntimeOptions): Record => ({ ...opts.env, EDGE_RUNTIME_PORT: String(opts.port), EDGE_RUNTIME_INSPECTOR_PORT: String(opts.inspectorPort), - FUNCTIONS_RUNTIME_CONFIG_PATH: join( - opts.runtimeRoot, - "edge-runtime", - "functions-runtime-config.json", - ), }); const edgeRuntimeArgs = ( @@ -115,13 +121,22 @@ export const makeEdgeRuntimeServiceDocker = (opts: DockerEdgeRuntimeOptions): Se ...(opts.projectDir === undefined ? [] : [`${opts.projectDir}:${opts.projectDir}:ro`]), ], args: ["--ulimit", edgeRuntimeNofileUlimit(opts.platformOs).arg], - env: { - ...edgeRuntimeEnv(opts), - FUNCTIONS_RUNTIME_CONFIG_PATH: `${bootstrapMountDir}/functions-runtime-config.json`, - }, + env: edgeRuntimeEnv(opts), user: hostUserForLinuxDocker(opts.runtime, opts.platformOs), cmd: [...edgeRuntimeArgs(opts, bootstrapMountDir)], dependencies: opts.dependencies, healthCheck: edgeRuntimeHealthCheck(opts.port), }); }; + +export const makeEdgeRuntimeServiceNative = (opts: NativeEdgeRuntimeOptions): ServiceDef => + nativeRunService({ + name: "edge-runtime", + command: `${opts.binPath}/bin/.edge-runtime-wrapped`, + args: [...edgeRuntimeArgs(opts, ".")], + cwd: opts.bootstrapDir, + env: edgeRuntimeEnv(opts), + posixResourceLimits: { nofileSoft: edgeRuntimeNofileSoftLimit }, + dependencies: opts.dependencies, + healthCheck: edgeRuntimeHealthCheck(opts.port), + }); diff --git a/packages/stack/src/services/imgproxy.ts b/packages/stack/src/services/imgproxy.ts index 2c9acf0bc7..9b831edb95 100644 --- a/packages/stack/src/services/imgproxy.ts +++ b/packages/stack/src/services/imgproxy.ts @@ -4,20 +4,29 @@ import type { StackIdentity } from "../StackIdentity.ts"; import { dockerRunService, hostHttpHealthCheck, + nativeRunService, type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerImgproxyOptions extends ContainerRuntimeOptions { - readonly image: string; +interface ImgproxyServiceOptions { readonly port: number; - readonly identity: StackIdentity; + /** Storage's caller-owned root, exposed to imgproxy through local:// URLs. */ readonly dataDir: string; - readonly platformOs: string; readonly dependencies: ReadonlyArray; } +export interface NativeImgproxyOptions extends ImgproxyServiceOptions { + readonly binPath: string; +} + +interface DockerImgproxyOptions extends ImgproxyServiceOptions, ContainerRuntimeOptions { + readonly image: string; + readonly identity: StackIdentity; + readonly platformOs: string; +} + const IMGPROXY_STORAGE_DIR = "/var/lib/storage"; const imgproxyHealthCheck = (port: number): ServiceDef["healthCheck"] => @@ -25,6 +34,36 @@ const imgproxyHealthCheck = (port: number): ServiceDef["healthCheck"] => ...stackHealthBudgets.imgproxy, }); +const normalizeLocalSourcePrefix = (dataDir: string): string => { + const withoutTrailingSeparators = dataDir.replace(/\/+$/u, ""); + return withoutTrailingSeparators.length === 0 ? "/" : `${withoutTrailingSeparators}/`; +}; + +const imgproxyEnv = (port: number, host: "127.0.0.1" | "", dataDir: string) => ({ + IMGPROXY_BIND: `${host}:${port}`, + // Storage emits absolute local:/// URLs. Keeping the root at / makes those + // URLs addressable on the host while the data directory remains owned by + // Storage and is not treated as imgproxy's independent state. + IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", + IMGPROXY_ALLOWED_SOURCES: `local:///${normalizeLocalSourcePrefix(dataDir)}`, + IMGPROXY_USE_ETAG: "/", + IMGPROXY_MAX_SRC_RESOLUTION: "50", + IMGPROXY_MAX_SRC_FILE_SIZE: "25000000", + IMGPROXY_MAX_ANIMATION_FRAMES: "60", + IMGPROXY_ENABLE_WEBP_DETECTION: "true", + IMGPROXY_PRESETS: "default=width:3000/height:8192", + IMGPROXY_FORMAT_QUALITY: "jpeg=80,avif=62,webp=80", +}); + +export const makeImgproxyServiceNative = (opts: NativeImgproxyOptions): ServiceDef => + nativeRunService({ + name: "imgproxy", + command: `${opts.binPath}/bin/imgproxy`, + env: imgproxyEnv(opts.port, "127.0.0.1", opts.dataDir), + dependencies: opts.dependencies, + healthCheck: imgproxyHealthCheck(opts.port), + }); + export const makeImgproxyServiceDocker = (opts: DockerImgproxyOptions): ServiceDef => dockerRunService({ runtime: opts.runtime, @@ -33,17 +72,7 @@ export const makeImgproxyServiceDocker = (opts: DockerImgproxyOptions): ServiceD image: opts.image, networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), volumes: [`${opts.dataDir}:${IMGPROXY_STORAGE_DIR}`], - env: { - IMGPROXY_BIND: `:${opts.port}`, - IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", - IMGPROXY_USE_ETAG: "/", - IMGPROXY_MAX_SRC_RESOLUTION: "50", - IMGPROXY_MAX_SRC_FILE_SIZE: "25000000", - IMGPROXY_MAX_ANIMATION_FRAMES: "60", - IMGPROXY_ENABLE_WEBP_DETECTION: "true", - IMGPROXY_PRESETS: "default=width:3000/height:8192", - IMGPROXY_FORMAT_QUALITY: "jpeg=80,avif=62,webp=80", - }, + env: imgproxyEnv(opts.port, "", IMGPROXY_STORAGE_DIR), dependencies: opts.dependencies, healthCheck: imgproxyHealthCheck(opts.port), }); diff --git a/packages/stack/src/services/mailpit.ts b/packages/stack/src/services/mailpit.ts index 193f408e02..71c39af889 100644 --- a/packages/stack/src/services/mailpit.ts +++ b/packages/stack/src/services/mailpit.ts @@ -1,29 +1,60 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- Mailpit's native database path is resolved synchronously while constructing the process definition. +import { join } from "node:path"; import type { ServiceDef } from "@supabase/process-compose"; import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; +import { removePathOnOrphanCleanup } from "./docker-cleanup.ts"; import { dockerRunService, hostHttpHealthCheck, + nativeRunService, type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerMailpitOptions extends ContainerRuntimeOptions { - readonly image: string; - readonly identity: StackIdentity; +interface MailpitServiceOptions { readonly webPort: number; readonly smtpPort: number; readonly pop3Port: number; - readonly platformOs: string; readonly dependencies: ReadonlyArray; } +const orphanCleanup = ( + opts: Pick & { cleanupDataDirOnExit?: boolean }, +) => + opts.cleanupDataDirOnExit ? removePathOnOrphanCleanup(opts.dataDir, { recursive: true }) : []; + +export interface NativeMailpitOptions extends MailpitServiceOptions { + readonly binPath: string; + /** Directory owned by this stack for Mailpit's SQLite database. */ + readonly dataDir: string; + readonly cleanupDataDirOnExit?: boolean; +} + +interface DockerMailpitOptions extends MailpitServiceOptions, ContainerRuntimeOptions { + readonly image: string; + readonly identity: StackIdentity; + readonly platformOs: string; +} + const mailpitHealthCheck = (port: number): ServiceDef["healthCheck"] => hostHttpHealthCheck(port, "/readyz", { ...stackHealthBudgets.mailpit, }); +const mailpitEnv = ( + opts: MailpitServiceOptions, + host: "127.0.0.1" | "0.0.0.0", + dataDir?: string, +): Record => ({ + MP_UI_BIND_ADDR: `${host}:${opts.webPort}`, + MP_SMTP_BIND_ADDR: `${host}:${opts.smtpPort}`, + MP_POP3_BIND_ADDR: `${host}:${opts.pop3Port}`, + MP_SMTP_DISABLE_RDNS: "true", + ...(dataDir === undefined ? {} : { MP_DATABASE: join(dataDir, "mailpit.db") }), +}); + export const makeMailpitServiceDocker = (opts: DockerMailpitOptions): ServiceDef => dockerRunService({ runtime: opts.runtime, @@ -32,11 +63,17 @@ export const makeMailpitServiceDocker = (opts: DockerMailpitOptions): ServiceDef image: opts.image, networkArgs: dockerNetworkArgs(opts.platformOs, [opts.webPort, opts.smtpPort, opts.pop3Port]), dependencies: opts.dependencies, - env: { - MP_UI_BIND_ADDR: `0.0.0.0:${opts.webPort}`, - MP_SMTP_BIND_ADDR: `0.0.0.0:${opts.smtpPort}`, - MP_POP3_BIND_ADDR: `0.0.0.0:${opts.pop3Port}`, - MP_SMTP_DISABLE_RDNS: "true", - }, + env: mailpitEnv(opts, "0.0.0.0"), healthCheck: mailpitHealthCheck(opts.webPort), }); + +export const makeMailpitServiceNative = (opts: NativeMailpitOptions): ServiceDef => ({ + ...nativeRunService({ + name: "mailpit", + command: `${opts.binPath}/bin/mailpit`, + env: mailpitEnv(opts, "127.0.0.1", opts.dataDir), + dependencies: opts.dependencies, + healthCheck: mailpitHealthCheck(opts.webPort), + }), + supervision: { orphanCleanup: orphanCleanup(opts) }, +}); diff --git a/packages/stack/src/services/nofile-limit.ts b/packages/stack/src/services/nofile-limit.ts index 2c2b8df9c6..68eb5f9086 100644 --- a/packages/stack/src/services/nofile-limit.ts +++ b/packages/stack/src/services/nofile-limit.ts @@ -1,6 +1,6 @@ // Raised from the daemon default so many concurrent Deno isolates can run // (supabase/cli#5151). -const desiredNofile = 65536; +export const edgeRuntimeNofileSoftLimit = 65536; // `userLimits.open_files.hard` from a `process.report.getReport()` diagnostic // report — a number or "unlimited". Narrowed structurally because getReport() @@ -29,7 +29,9 @@ const hostHardNofileLimit = (platformOs: string): number | undefined => // (elsewhere the daemon runs in a VM), and only downward: a client more // constrained than its daemon yields a smaller fd budget, never a failed start. export const clampNofileLimit = (hardLimit: number | undefined): number => - hardLimit === undefined ? desiredNofile : Math.min(desiredNofile, hardLimit); + hardLimit === undefined + ? edgeRuntimeNofileSoftLimit + : Math.min(edgeRuntimeNofileSoftLimit, hardLimit); interface EdgeRuntimeNofileUlimit { /** The docker `--ulimit` value, `nofile=:`. */ @@ -49,10 +51,10 @@ export const edgeRuntimeNofileUlimit = ( return { arg: `nofile=${limit}:${limit}`, limit, - ...(limit < desiredNofile && { + ...(limit < edgeRuntimeNofileSoftLimit && { clampWarning: `Edge Runtime file descriptor limit lowered to ${limit}: ` + - `the host's hard limit (ulimit -Hn) is below the default ${desiredNofile}. ` + + `the host's hard limit (ulimit -Hn) is below the default ${edgeRuntimeNofileSoftLimit}. ` + `Heavy Edge Function workloads may exhaust file descriptors.`, }), }; diff --git a/packages/stack/src/services/pgmeta.ts b/packages/stack/src/services/pgmeta.ts index fe5d74631b..d12d66c981 100644 --- a/packages/stack/src/services/pgmeta.ts +++ b/packages/stack/src/services/pgmeta.ts @@ -3,21 +3,42 @@ import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; import { dockerRunService, + nativeRunService, type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerPgmetaOptions extends ContainerRuntimeOptions { - readonly image: string; - readonly identity: StackIdentity; +interface PgmetaServiceOptions { readonly port: number; readonly dbHost: string; readonly dbPort: number; - readonly platformOs: string; readonly dependencies: ReadonlyArray; } +export interface NativePgmetaOptions extends Omit { + readonly binPath: string; +} + +interface DockerPgmetaOptions extends PgmetaServiceOptions, ContainerRuntimeOptions { + readonly image: string; + readonly identity: StackIdentity; + readonly platformOs: string; +} + +const pgmetaEnv = ( + opts: Omit & { readonly dbHost?: string }, + host?: string, +): Record => ({ + ...(host === undefined ? {} : { PG_META_HOST: host }), + PG_META_PORT: String(opts.port), + PG_META_DB_HOST: opts.dbHost ?? "127.0.0.1", + PG_META_DB_NAME: "postgres", + PG_META_DB_USER: "postgres", + PG_META_DB_PORT: String(opts.dbPort), + PG_META_DB_PASSWORD: "postgres", +}); + const pgmetaHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ probe: { _tag: "Http", @@ -36,14 +57,16 @@ export const makePgmetaServiceDocker = (opts: DockerPgmetaOptions): ServiceDef = identity: opts.identity, image: opts.image, networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), - env: { - PG_META_PORT: String(opts.port), - PG_META_DB_HOST: opts.dbHost, - PG_META_DB_NAME: "postgres", - PG_META_DB_USER: "postgres", - PG_META_DB_PORT: String(opts.dbPort), - PG_META_DB_PASSWORD: "postgres", - }, + env: pgmetaEnv(opts), + dependencies: opts.dependencies, + healthCheck: pgmetaHealthCheck(opts.port), + }); + +export const makePgmetaServiceNative = (opts: NativePgmetaOptions): ServiceDef => + nativeRunService({ + name: "pgmeta", + command: `${opts.binPath}/bin/pgmeta`, + env: pgmetaEnv(opts, "127.0.0.1"), dependencies: opts.dependencies, healthCheck: pgmetaHealthCheck(opts.port), }); diff --git a/packages/stack/src/services/pooler.ts b/packages/stack/src/services/pooler.ts index 10e53633bb..2953d9da9c 100644 --- a/packages/stack/src/services/pooler.ts +++ b/packages/stack/src/services/pooler.ts @@ -1,8 +1,12 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- Native crash dumps are rooted in the caller-owned runtime directory. +import { join } from "node:path"; import type { ServiceDef } from "@supabase/process-compose"; import { dockerPortMapArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; import { dockerRunService, + nativeBeamLoopbackEnv, + nativeRunService, type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; @@ -10,11 +14,7 @@ import { stackHealthBudgets } from "./health-budgets.ts"; type PoolMode = "transaction" | "session"; -interface DockerPoolerOptions extends ContainerRuntimeOptions { - readonly image: string; - readonly identity: StackIdentity; - readonly hostAdminPort: number; - readonly hostPort: number; +interface PoolerServiceOptions { readonly dbHost: string; readonly dbPort: number; readonly poolMode: PoolMode; @@ -24,10 +24,40 @@ interface DockerPoolerOptions extends ContainerRuntimeOptions { readonly tenantId: string; readonly encryptionKey: string; readonly secretKeyBase: string; - readonly platformOs: string; readonly dependencies: ReadonlyArray; } +export interface NativePoolerOptions extends Omit { + readonly binPath: string; + readonly runtimeRoot: string; + readonly adminPort: number; + readonly sessionPort: number; + readonly transactionPort: number; + /** Base of the eight private Supavisor shard listeners (four per mode). */ + readonly internalPort: number; + /** Stack-unique Erlang short name and cookie for host-level distribution. */ + readonly nodeName: string; + readonly releaseCookie: string; +} + +export interface NativePoolerServiceBundle { + /** Runs the bundled Supavisor migration once before tenant bootstrap. */ + readonly migrate: ServiceDef; + /** Idempotently creates the local tenant after migrations complete. */ + readonly bootstrap: ServiceDef; + /** The public, long-running Supavisor server. */ + readonly server: ServiceDef; +} + +interface DockerPoolerOptions extends PoolerServiceOptions, ContainerRuntimeOptions { + readonly image: string; + readonly identity: StackIdentity; + readonly platformOs: string; + readonly hostAdminPort: number; + readonly hostSessionPort: number; + readonly hostTransactionPort: number; +} + const poolerHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ probe: { _tag: "Http", @@ -39,8 +69,56 @@ const poolerHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ ...stackHealthBudgets.pooler, }); +/** Encode an untrusted StackConfig value as an Elixir double-quoted string. */ +const elixirStringLiteral = (value: string): string => { + let escaped = ""; + const characters = Array.from(value); + for (const [index, character] of characters.entries()) { + const codePoint = character.codePointAt(0); + if (codePoint === undefined) continue; + switch (codePoint) { + case 0x08: + escaped += "\\b"; + break; + case 0x09: + escaped += "\\t"; + break; + case 0x0a: + escaped += "\\n"; + break; + case 0x0c: + escaped += "\\f"; + break; + case 0x0d: + escaped += "\\r"; + break; + case 0x22: + escaped += '\\"'; + break; + case 0x5c: + escaped += "\\\\"; + break; + case 0x23: + escaped += characters[index + 1] === "{" ? "\\#" : character; + break; + default: + escaped += + codePoint < 0x20 || codePoint === 0x7f || (codePoint >= 0xd800 && codePoint <= 0xdfff) + ? `\\u${codePoint.toString(16).padStart(4, "0")}` + : character; + } + } + return `"${escaped}"`; +}; + +/** Keep the generated eval source a single shell argument for Docker. */ +const shellSingleQuoted = (value: string): string => `'${value.replaceAll("'", `'"'"'`)}'`; + const tenantScript = ( - opts: DockerPoolerOptions, + opts: Pick< + PoolerServiceOptions, + "tenantId" | "dbHost" | "dbPort" | "maxClientConn" | "defaultPoolSize" | "poolMode" + >, ) => `{:ok, _} = Application.ensure_all_started(:supavisor) {:ok, version} = case Supavisor.Repo.query!("select version()") do @@ -49,8 +127,8 @@ const tenantScript = ( end params = %{ - "external_id" => "${opts.tenantId}", - "db_host" => "${opts.dbHost}", + "external_id" => ${elixirStringLiteral(opts.tenantId)}, + "db_host" => ${elixirStringLiteral(opts.dbHost)}, "db_port" => ${opts.dbPort}, "db_database" => "postgres", "require_user" => false, @@ -61,7 +139,7 @@ params = %{ "users" => [%{ "db_user" => "pgbouncer", "db_password" => "postgres", - "mode_type" => "${opts.poolMode}", + "mode_type" => ${elixirStringLiteral(opts.poolMode)}, "pool_size" => ${opts.defaultPoolSize}, "is_manager" => true }] @@ -80,13 +158,8 @@ export const makePoolerServiceDocker = (opts: DockerPoolerOptions): ServiceDef = image: opts.image, networkArgs: dockerPortMapArgs(opts.platformOs, [ { host: opts.hostAdminPort, container: poolerContainerPorts.admin }, - { - host: opts.hostPort, - container: - opts.poolMode === "session" - ? poolerContainerPorts.session - : poolerContainerPorts.transaction, - }, + { host: opts.hostSessionPort, container: poolerContainerPorts.session }, + { host: opts.hostTransactionPort, container: poolerContainerPorts.transaction }, ]), env: { PORT: String(poolerContainerPorts.admin), @@ -106,13 +179,83 @@ export const makePoolerServiceDocker = (opts: DockerPoolerOptions): ServiceDef = cmd: [ "/bin/sh", "-c", - `/app/bin/migrate && /app/bin/supavisor eval '${tenantScript(opts)}' && /app/bin/server`, + `/app/bin/migrate && /app/bin/supavisor eval ${shellSingleQuoted( + tenantScript({ + tenantId: opts.tenantId, + dbHost: opts.dbHost, + dbPort: opts.dbPort, + poolMode: opts.poolMode, + defaultPoolSize: opts.defaultPoolSize, + maxClientConn: opts.maxClientConn, + }), + )} && /app/bin/server`, ], dependencies: opts.dependencies, healthCheck: poolerHealthCheck(opts.hostAdminPort), }); })(); +const poolerNativeShardPorts = (basePort: number, offset: number): string => + Array.from({ length: 4 }, (_, index) => String(basePort + offset + index)).join(","); + +const poolerNativeEnv = (opts: NativePoolerOptions): Record => ({ + PORT: String(opts.adminPort), + PROXY_PORT_SESSION: String(opts.sessionPort), + PROXY_PORT_TRANSACTION: String(opts.transactionPort), + SESSION_PROXY_PORTS: poolerNativeShardPorts(opts.internalPort, 0), + TRANSACTION_PROXY_PORTS: poolerNativeShardPorts(opts.internalPort, 4), + DATABASE_URL: `ecto://postgres:postgres@127.0.0.1:${opts.dbPort}/_supabase`, + CLUSTER_POSTGRES: "true", + SECRET_KEY_BASE: opts.secretKeyBase, + VAULT_ENC_KEY: opts.encryptionKey, + API_JWT_SECRET: opts.jwtSecret, + METRICS_JWT_SECRET: opts.jwtSecret, + REGION: "local", + RUN_JANITOR: "true", + ...nativeBeamLoopbackEnv, + RLIMIT_NOFILE: "", + ELIXIR_ERL_OPTIONS: "+fnu +S 1:1 +SDio 1 +sbwt none +sbwtdcpu none +sbwtdio none", + ERL_CRASH_DUMP: join(opts.runtimeRoot, "pooler", "erl_crash.dump"), + NODE_NAME: opts.nodeName, + NODE_IP: "127.0.0.1", + RELEASE_NODE: `${opts.nodeName}@127.0.0.1`, + RELEASE_COOKIE: opts.releaseCookie, +}); + +export const makePoolerServicesNative = (opts: NativePoolerOptions): NativePoolerServiceBundle => { + const env = poolerNativeEnv(opts); + const bootstrapEnv = { + ...env, + PORT: "0", + PROXY_PORT: "0", + PROXY_PORT_SESSION: "0", + PROXY_PORT_TRANSACTION: "0", + }; + const migrate = nativeRunService({ + name: "pooler-migrate", + command: `${opts.binPath}/bin/migrate`, + env, + dependencies: opts.dependencies, + restart: "no", + }); + const bootstrap = nativeRunService({ + name: "pooler-bootstrap", + command: `${opts.binPath}/bin/supavisor`, + args: ["eval", tenantScript({ ...opts, dbHost: "127.0.0.1" })], + env: bootstrapEnv, + dependencies: [{ service: migrate.name, condition: "completed" }], + restart: "no", + }); + const server = nativeRunService({ + name: "pooler", + command: `${opts.binPath}/bin/server`, + env, + dependencies: [{ service: bootstrap.name, condition: "completed" }], + healthCheck: poolerHealthCheck(opts.adminPort), + }); + return { migrate, bootstrap, server }; +}; + export const poolerContainerPorts = { admin: 4000, session: 5432, diff --git a/packages/stack/src/services/pooler.unit.test.ts b/packages/stack/src/services/pooler.unit.test.ts new file mode 100644 index 0000000000..6eb35ceb64 --- /dev/null +++ b/packages/stack/src/services/pooler.unit.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { makePoolerServicesNative } from "./pooler.ts"; + +const dependencies = [{ service: "postgres-init", condition: "completed" }] as const; + +const makePooler = (mode: "transaction" | "session", tenantId = "pooler-dev") => + makePoolerServicesNative({ + binPath: "/cache/pooler/v2.9.10/darwin-arm64", + runtimeRoot: "/tmp/stacks/project-a/runtime", + adminPort: 54329, + sessionPort: 54330, + transactionPort: 54331, + internalPort: 54400, + nodeName: "supavisor_id_stack_a", + releaseCookie: "supabase_stack_a_cookie", + dbPort: 54322, + poolMode: mode, + defaultPoolSize: 20, + maxClientConn: 100, + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + tenantId, + encryptionKey: "12345678901234567890123456789012", + secretKeyBase: "1234567890123456789012345678901234567890123456789012345678901234", + dependencies, + }); + +describe("makePoolerServicesNative", () => { + it("runs migrate, tenant bootstrap, and server in dependency order", () => { + const bundle = makePooler("transaction"); + + expect(bundle.migrate).toMatchObject({ + name: "pooler-migrate", + command: "/cache/pooler/v2.9.10/darwin-arm64/bin/migrate", + restart: "no", + dependencies, + }); + expect(bundle.bootstrap).toMatchObject({ + name: "pooler-bootstrap", + command: "/cache/pooler/v2.9.10/darwin-arm64/bin/supavisor", + restart: "no", + dependencies: [{ service: "pooler-migrate", condition: "completed" }], + }); + expect(bundle.bootstrap.env).toMatchObject({ + PORT: "0", + PROXY_PORT: "0", + PROXY_PORT_SESSION: "0", + PROXY_PORT_TRANSACTION: "0", + SESSION_PROXY_PORTS: "54400,54401,54402,54403", + TRANSACTION_PROXY_PORTS: "54404,54405,54406,54407", + DATABASE_URL: "ecto://postgres:postgres@127.0.0.1:54322/_supabase", + API_JWT_SECRET: "super-secret-jwt-token-with-at-least-32-characters-long", + NODE_NAME: "supavisor_id_stack_a", + RELEASE_COOKIE: "supabase_stack_a_cookie", + }); + expect(bundle.bootstrap.args?.[0]).toBe("eval"); + expect(bundle.bootstrap.args?.[1]).toContain('"external_id" => "pooler-dev"'); + expect(bundle.server).toMatchObject({ + name: "pooler", + command: "/cache/pooler/v2.9.10/darwin-arm64/bin/server", + restart: "unless-stopped", + dependencies: [{ service: "pooler-bootstrap", condition: "completed" }], + }); + expect(bundle.server.healthCheck?.probe).toEqual({ + _tag: "Http", + host: "127.0.0.1", + port: 54329, + path: "/api/health", + scheme: "http", + }); + }); + + it("binds both public proxy listeners with the stack identity", () => { + const transaction = makePooler("transaction").server; + expect(transaction.env).toMatchObject({ + PORT: "54329", + PROXY_PORT_TRANSACTION: "54331", + PROXY_PORT_SESSION: "54330", + SESSION_PROXY_PORTS: "54400,54401,54402,54403", + TRANSACTION_PROXY_PORTS: "54404,54405,54406,54407", + NODE_NAME: "supavisor_id_stack_a", + NODE_IP: "127.0.0.1", + RELEASE_NODE: "supavisor_id_stack_a@127.0.0.1", + RELEASE_COOKIE: "supabase_stack_a_cookie", + ELIXIR_ERL_OPTIONS: "+fnu +S 1:1 +SDio 1 +sbwt none +sbwtdcpu none +sbwtdio none", + ERL_CRASH_DUMP: "/tmp/stacks/project-a/runtime/pooler/erl_crash.dump", + }); + + const session = makePooler("session").server; + expect(session.env).toMatchObject({ + PROXY_PORT_TRANSACTION: "54331", + PROXY_PORT_SESSION: "54330", + }); + }); + + it("encodes tenant IDs as safe Elixir string literals", () => { + const tenantId = 'tenant"\\\\\n\r\t\u0000'; + const bundle = makePooler("transaction", tenantId); + + const script = bundle.bootstrap.args?.[1]; + expect(script).toContain('"external_id" => "tenant\\\"\\\\\\\\\\n\\r\\t\\u0000"'); + }); + + it("escapes Elixir interpolation in tenant IDs", () => { + const tenantId = 'tenant#{System.cmd("echo", ["unsafe"])}'; + const script = makePooler("transaction", tenantId).bootstrap.args?.[1]; + + expect(script).toContain( + '"external_id" => "tenant\\#{System.cmd(\\"echo\\", [\\"unsafe\\"])}"', + ); + }); +}); diff --git a/packages/stack/src/services/postgres.ts b/packages/stack/src/services/postgres.ts index b08337bf9e..5895be239a 100644 --- a/packages/stack/src/services/postgres.ts +++ b/packages/stack/src/services/postgres.ts @@ -57,11 +57,25 @@ const postgresGetKeyScript = (binPath: string): string => const postgresHealthCheck = (binPath: string, port: number) => ({ probe: { _tag: "Exec" as const, - command: `${binPath}/bin/pg_isready`, - args: ["-h", "127.0.0.1", "-p", String(port), "-U", "postgres"], + command: `${binPath}/bin/psql`, + args: [ + "-h", + "127.0.0.1", + "-p", + String(port), + "-U", + "supabase_admin", + "-d", + "postgres", + "--no-psqlrc", + "--no-password", + "-c", + "SELECT 1", + ], env: { DYLD_LIBRARY_PATH: `${binPath}/lib`, LD_LIBRARY_PATH: `${binPath}/lib`, + PGPASSWORD: "postgres", }, }, ...stackHealthBudgets.postgresNative, diff --git a/packages/stack/src/services/realtime.ts b/packages/stack/src/services/realtime.ts index 0783ed830f..ff1d5d98ab 100644 --- a/packages/stack/src/services/realtime.ts +++ b/packages/stack/src/services/realtime.ts @@ -3,15 +3,16 @@ import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; import { dockerRunService, + hostHttpHealthCheck, + nativeBeamLoopbackEnv, + nativeRunService, type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerRealtimeOptions extends ContainerRuntimeOptions { - readonly image: string; +interface RealtimeServiceOptions { readonly port: number; - readonly identity: StackIdentity; readonly dbHost: string; readonly dbPort: number; readonly jwtSecret: string; @@ -20,11 +21,70 @@ interface DockerRealtimeOptions extends ContainerRuntimeOptions { readonly encryptionKey: string; readonly secretKeyBase: string; readonly maxHeaderLength: number; - readonly platformOs: string; readonly dependencies: ReadonlyArray; } -const realtimeHealthCheck = (port: number, tenantId: string): ServiceDef["healthCheck"] => ({ +export interface NativeRealtimeOptions extends Omit { + readonly binPath: string; + /** Stack-unique Erlang short name and cookie for host-level distribution. */ + readonly nodeName: string; + readonly releaseCookie: string; +} + +export interface NativeRealtimeServiceBundle { + /** Runs the bundled database migration once before the application starts. */ + readonly migrate: ServiceDef; + /** Seeds the self-hosted tenant once after migrations complete. */ + readonly seed: ServiceDef; + /** The public, long-running Realtime service. */ + readonly server: ServiceDef; +} + +interface DockerRealtimeOptions extends RealtimeServiceOptions, ContainerRuntimeOptions { + readonly image: string; + readonly identity: StackIdentity; + readonly platformOs: string; +} + +const realtimeEnv = ( + opts: Omit & { + readonly dbHost?: string; + readonly dbUser?: string; + readonly nodeName?: string; + readonly releaseCookie?: string; + }, +): Record => ({ + PORT: String(opts.port), + DB_HOST: opts.dbHost ?? "127.0.0.1", + DB_PORT: String(opts.dbPort), + DB_USER: opts.dbUser ?? "postgres", + DB_PASSWORD: "postgres", + DB_NAME: "postgres", + DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", + DB_ENC_KEY: opts.encryptionKey, + API_JWT_SECRET: opts.jwtSecret, + API_JWT_JWKS: opts.jwtJwks, + METRICS_JWT_SECRET: opts.jwtSecret, + APP_NAME: "realtime", + SECRET_KEY_BASE: opts.secretKeyBase, + ERL_AFLAGS: "-proto_dist inet_tcp", + DNS_NODES: "", + RLIMIT_NOFILE: "", + SEED_SELF_HOST: "true", + RUN_JANITOR: "true", + MAX_HEADER_LENGTH: String(opts.maxHeaderLength), + ...(opts.nodeName === undefined || opts.releaseCookie === undefined + ? {} + : { + ...nativeBeamLoopbackEnv, + NODE_NAME: opts.nodeName, + NODE_IP: "127.0.0.1", + RELEASE_NODE: `${opts.nodeName}@127.0.0.1`, + RELEASE_COOKIE: opts.releaseCookie, + }), +}); + +const realtimeDockerHealthCheck = (port: number, tenantId: string): ServiceDef["healthCheck"] => ({ probe: { _tag: "Exec", command: "curl", @@ -41,6 +101,12 @@ const realtimeHealthCheck = (port: number, tenantId: string): ServiceDef["health ...stackHealthBudgets.realtime, }); +const realtimeNativeHealthCheck = (port: number, tenantId: string): ServiceDef["healthCheck"] => + hostHttpHealthCheck(port, "/api/ping", { + ...stackHealthBudgets.realtime, + headers: { Host: tenantId }, + }); + export const makeRealtimeServiceDocker = (opts: DockerRealtimeOptions): ServiceDef => dockerRunService({ runtime: opts.runtime, @@ -48,27 +114,36 @@ export const makeRealtimeServiceDocker = (opts: DockerRealtimeOptions): ServiceD identity: opts.identity, image: opts.image, networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), - env: { - PORT: String(opts.port), - DB_HOST: opts.dbHost, - DB_PORT: String(opts.dbPort), - DB_USER: "postgres", - DB_PASSWORD: "postgres", - DB_NAME: "postgres", - DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", - DB_ENC_KEY: opts.encryptionKey, - API_JWT_SECRET: opts.jwtSecret, - API_JWT_JWKS: opts.jwtJwks, - METRICS_JWT_SECRET: opts.jwtSecret, - APP_NAME: "realtime", - SECRET_KEY_BASE: opts.secretKeyBase, - ERL_AFLAGS: "-proto_dist inet_tcp", - DNS_NODES: "", - RLIMIT_NOFILE: "", - SEED_SELF_HOST: "true", - RUN_JANITOR: "true", - MAX_HEADER_LENGTH: String(opts.maxHeaderLength), - }, + env: realtimeEnv(opts), dependencies: opts.dependencies, - healthCheck: realtimeHealthCheck(opts.port, opts.tenantId), + healthCheck: realtimeDockerHealthCheck(opts.port, opts.tenantId), + }); + +export const makeRealtimeServicesNative = ( + opts: NativeRealtimeOptions, +): NativeRealtimeServiceBundle => { + const env = realtimeEnv({ ...opts, dbUser: "supabase_admin" }); + const migrate = nativeRunService({ + name: "realtime-migrate", + command: `${opts.binPath}/bin/migrate`, + env, + dependencies: opts.dependencies, + restart: "no", + }); + const seed = nativeRunService({ + name: "realtime-seed", + command: `${opts.binPath}/bin/realtime`, + args: ["eval", "Realtime.Release.seeds(Realtime.Repo)"], + env, + dependencies: [{ service: migrate.name, condition: "completed" }], + restart: "no", + }); + const server = nativeRunService({ + name: "realtime", + command: `${opts.binPath}/bin/server`, + env, + dependencies: [{ service: seed.name, condition: "completed" }], + healthCheck: realtimeNativeHealthCheck(opts.port, opts.tenantId), }); + return { migrate, seed, server }; +}; diff --git a/packages/stack/src/services/service-utils.ts b/packages/stack/src/services/service-utils.ts index f26ddd9390..fdc9e19cd0 100644 --- a/packages/stack/src/services/service-utils.ts +++ b/packages/stack/src/services/service-utils.ts @@ -32,6 +32,45 @@ interface DockerRunServiceOptions extends ContainerRuntimeOptions { readonly orphanCleanup?: ReadonlyArray; } +export interface NativeRunServiceOptions { + /** + * Native definitions may include private one-shot helpers that are not part + * of the public service catalog (for example Realtime's migration step). + */ + readonly name: string; + readonly command: string; + readonly args?: ReadonlyArray; + readonly env?: Record; + readonly cwd?: string; + readonly dependencies: ReadonlyArray; + readonly posixResourceLimits?: ServiceDef["posixResourceLimits"]; + readonly healthCheck?: ServiceDef["healthCheck"]; + readonly shutdown?: ServiceDef["shutdown"]; + readonly restart?: ServiceDef["restart"]; +} + +/** Keep native Erlang distribution on the owning stack's loopback interface. */ +export const nativeBeamLoopbackEnv = { + ERL_AFLAGS: "-proto_dist inet_tcp -kernel inet_dist_use_interface '{127,0,0,1}'", + ERL_EPMD_ADDRESS: "127.0.0.1", +} as const; + +export const nativeRunService = (opts: NativeRunServiceOptions): ServiceDef => ({ + name: opts.name, + command: opts.command, + ...(opts.args === undefined ? {} : { args: opts.args }), + ...(opts.env === undefined ? {} : { env: opts.env }), + ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), + dependencies: opts.dependencies, + ...(opts.posixResourceLimits === undefined + ? {} + : { posixResourceLimits: opts.posixResourceLimits }), + ...(opts.healthCheck === undefined ? {} : { healthCheck: opts.healthCheck }), + ...(opts.shutdown === undefined ? {} : { shutdown: opts.shutdown }), + supervision: {}, + restart: opts.restart ?? "unless-stopped", +}); + export const hostUserForLinuxDocker = ( runtime: ContainerRuntime, platformOs: string, @@ -50,17 +89,23 @@ const envArgs = (env: Record): ReadonlyArray => export const hostHttpHealthCheck = ( port: number, path: string, - opts: Omit = {}, -): ServiceDef["healthCheck"] => ({ - probe: { - _tag: "Http", - host: "127.0.0.1", - port, - path, - scheme: "http", - }, - ...opts, -}); + opts: Omit, "probe"> & { + readonly headers?: Readonly>; + } = {}, +): ServiceDef["healthCheck"] => { + const { headers, ...healthCheck } = opts; + return { + probe: { + _tag: "Http", + host: "127.0.0.1", + port, + path, + scheme: "http", + ...(headers === undefined ? {} : { headers }), + }, + ...healthCheck, + }; +}; export const dockerExecHealthCheck = ( runtime: ContainerRuntime, diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index bff157821a..30f9fca52a 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -1,29 +1,35 @@ // oxlint-disable effecttsgo/node-builtin-import -- Service tests use native filesystem/path fixtures to validate service wiring. -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { Predicate } from "effect"; +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; import { analyticsDockerRuntimeNetwork, makeAnalyticsServiceDocker } from "./analytics.ts"; import { makeAuthServiceNative, makeAuthServiceDocker } from "./auth.ts"; -import { makeEdgeRuntimeServiceDocker } from "./edge-runtime.ts"; +import { + makeEdgeRuntimeServiceDocker, + makeEdgeRuntimeServiceNative, + prepareEdgeRuntimeBootstrap, +} from "./edge-runtime.ts"; import { edgeRuntimeNofileUlimit } from "./nofile-limit.ts"; -import { makeImgproxyServiceDocker } from "./imgproxy.ts"; -import { makeMailpitServiceDocker } from "./mailpit.ts"; -import { makePgmetaServiceDocker } from "./pgmeta.ts"; +import { makeImgproxyServiceDocker, makeImgproxyServiceNative } from "./imgproxy.ts"; +import { makeMailpitServiceDocker, makeMailpitServiceNative } from "./mailpit.ts"; +import { makePgmetaServiceDocker, makePgmetaServiceNative } from "./pgmeta.ts"; import { makePostgresInitService, makePostgresInitServiceDocker } from "./postgres-init.ts"; import { makePostgresService, makePostgresServiceDocker } from "./postgres.ts"; import { makePostgrestService, makePostgrestServiceDocker } from "./postgrest.ts"; -import { makeRealtimeServiceDocker } from "./realtime.ts"; +import { makeRealtimeServiceDocker, makeRealtimeServicesNative } from "./realtime.ts"; import { makePoolerServiceDocker, poolerContainerPorts } from "./pooler.ts"; import { dockerRunService } from "./service-utils.ts"; import { LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, + makeStorageServiceNative, makeStorageServiceDocker, } from "./storage.ts"; -import { makeStudioServiceDocker } from "./studio.ts"; +import { makeStudioServiceDocker, makeStudioServiceNative } from "./studio.ts"; import { makeVectorServiceDocker } from "./vector.ts"; import { stackIdentity, type StackIdentity } from "../StackIdentity.ts"; import { DEFAULT_VERSIONS, dockerImageForService } from "../versions.ts"; @@ -86,11 +92,25 @@ describe("makePostgresService", () => { expect(def.env?.DYLD_LIBRARY_PATH).toBe(`${POSTGRES_BIN_PATH}/lib`); expect(def.healthCheck?.probe).toEqual({ _tag: "Exec", - command: `${POSTGRES_BIN_PATH}/bin/pg_isready`, - args: ["-h", "127.0.0.1", "-p", "54322", "-U", "postgres"], + command: `${POSTGRES_BIN_PATH}/bin/psql`, + args: [ + "-h", + "127.0.0.1", + "-p", + "54322", + "-U", + "supabase_admin", + "-d", + "postgres", + "--no-psqlrc", + "--no-password", + "-c", + "SELECT 1", + ], env: { DYLD_LIBRARY_PATH: `${POSTGRES_BIN_PATH}/lib`, LD_LIBRARY_PATH: `${POSTGRES_BIN_PATH}/lib`, + PGPASSWORD: "postgres", }, }); expect(def.dependencies).toEqual([]); @@ -145,6 +165,7 @@ describe("makeStudioServiceDocker", () => { SUPABASE_SECRET_KEY: "sb_secret_test", S3_PROTOCOL_ACCESS_KEY_ID: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, S3_PROTOCOL_ACCESS_KEY_SECRET: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, + HOSTNAME: "0.0.0.0", }); expect(def.args).not.toContain("sb_secret_test"); }); @@ -355,7 +376,6 @@ describe("makeEdgeRuntimeServiceDocker", () => { runtime: "docker", image: dockerImageForService("edge-runtime", DEFAULT_VERSIONS["edge-runtime"]), identity: EPHEMERAL_IDENTITY, - runtimeRoot: tempDir, bootstrapDir: path.join(tempDir, "edge-runtime"), port: 54340, inspectorPort: 54341, @@ -374,6 +394,9 @@ describe("makeEdgeRuntimeServiceDocker", () => { expect(def.args).toContain(`--port=54340`); expect(def.args).toContain(`--policy=per_worker`); expect(def.args).toContain(`${bootstrapDir}:/workspace:ro`); + expect(def.args).not.toContain("--static"); + expect(def.args).not.toContain("/workspace/functions-runtime-config.json"); + expect(def.env?.FUNCTIONS_RUNTIME_CONFIG_PATH).toBeUndefined(); expect(def.args).toContain("--ulimit"); expect(def.args).toContain(edgeRuntimeNofileUlimit("linux").arg); expect(def.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); @@ -388,6 +411,322 @@ describe("makeEdgeRuntimeServiceDocker", () => { rmSync(tempDir, { recursive: true, force: true }); } }); + + it.effect("embeds the generated runtime config in the bootstrap module graph", () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "stack-edge-runtime-bootstrap-")); + + return Effect.gen(function* () { + const bootstrapDir = yield* prepareEdgeRuntimeBootstrap(tempDir); + const source = yield* Effect.sync(() => + readFileSync(path.join(bootstrapDir, "index.ts"), "utf8"), + ); + + expect(source).toContain( + 'import functionsRuntimeConfig from "./functions-runtime-config.json" with { type: "json" };', + ); + expect(source).toMatch(/start\(functionsRuntimeConfig\);\s*$/); + }).pipe( + Effect.provide(NodeServices.layer), + Effect.ensuring(Effect.sync(() => rmSync(tempDir, { recursive: true, force: true }))), + ); + }); +}); + +describe("native auxiliary service definitions", () => { + it("starts Edge Runtime from the prepared wrapper and bootstrap directory", () => { + const artifactRoot = "/cache/edge-runtime/v1.74.3/darwin-arm64"; + const runtimeRoot = "/tmp/stacks/project-a/runtime"; + const projectDir = "/tmp/stacks/project-a/project"; + const bootstrapDir = `${runtimeRoot}/edge-runtime`; + const def = makeEdgeRuntimeServiceNative({ + binPath: artifactRoot, + projectDir, + bootstrapDir, + port: 54340, + inspectorPort: 54341, + policy: "per_worker", + env: { SUPABASE_INTERNAL_DEBUG: "true" }, + dependencies: [{ service: "postgres", condition: "healthy" }], + }); + + expect(def.command).toBe(`${artifactRoot}/bin/.edge-runtime-wrapped`); + expect(def.posixResourceLimits).toEqual({ nofileSoft: 65536 }); + expect(def.args).toEqual(["start", "--main-service=.", "--port=54340", "--policy=per_worker"]); + expect(def.cwd).toBe(bootstrapDir); + expect(def.env).toMatchObject({ + SUPABASE_INTERNAL_DEBUG: "true", + }); + expect(def.env?.FUNCTIONS_RUNTIME_CONFIG_PATH).toBeUndefined(); + expect(def.healthCheck?.probe).toEqual({ + _tag: "Http", + host: "127.0.0.1", + port: 54340, + path: "/_internal/health", + scheme: "http", + }); + }); + + it("orchestrates Realtime migration, seed, and server through rooted host entrypoints", () => { + const artifactRoot = "/cache/realtime/v2.129.1/linux-amd64"; + const dependencies = [{ service: "postgres", condition: "healthy" }] as const; + const bundle = makeRealtimeServicesNative({ + binPath: artifactRoot, + nodeName: "realtime_id_stack_a", + releaseCookie: "supabase_stack_a_cookie", + port: 54330, + dbPort: DB_PORT, + jwtSecret: JWT_SECRET, + jwtJwks: "native-jwks", + tenantId: "native-realtime", + encryptionKey: "native-encryption-key", + secretKeyBase: "native-secret-key-base", + maxHeaderLength: 4096, + dependencies, + }); + + expect(bundle.migrate).toMatchObject({ + name: "realtime-migrate", + command: `${artifactRoot}/bin/migrate`, + restart: "no", + dependencies, + }); + expect(bundle.seed).toMatchObject({ + name: "realtime-seed", + command: `${artifactRoot}/bin/realtime`, + args: ["eval", "Realtime.Release.seeds(Realtime.Repo)"], + restart: "no", + dependencies: [{ service: "realtime-migrate", condition: "completed" }], + }); + expect(bundle.server).toMatchObject({ + name: "realtime", + command: `${artifactRoot}/bin/server`, + dependencies: [{ service: "realtime-seed", condition: "completed" }], + restart: "unless-stopped", + }); + expect(bundle.server.env).toMatchObject({ + PORT: "54330", + DB_HOST: "127.0.0.1", + DB_PORT: String(DB_PORT), + DB_USER: "supabase_admin", + DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", + API_JWT_SECRET: JWT_SECRET, + API_JWT_JWKS: "native-jwks", + SECRET_KEY_BASE: "native-secret-key-base", + MAX_HEADER_LENGTH: "4096", + NODE_NAME: "realtime_id_stack_a", + NODE_IP: "127.0.0.1", + RELEASE_NODE: "realtime_id_stack_a@127.0.0.1", + RELEASE_COOKIE: "supabase_stack_a_cookie", + }); + expect(bundle.server.healthCheck?.probe).toEqual({ + _tag: "Http", + host: "127.0.0.1", + port: 54330, + path: "/api/ping", + scheme: "http", + headers: { Host: "native-realtime" }, + }); + }); + + it("starts PgMeta from its published host launcher against loopback PostgreSQL", () => { + const artifactRoot = "/cache/pgmeta/v0.98.0/linux-amd64"; + const dependencies = [{ service: "postgres", condition: "healthy" }] as const; + const def = makePgmetaServiceNative({ + binPath: artifactRoot, + port: 54336, + dbPort: DB_PORT, + dependencies, + }); + + expect(def.command).toBe(`${artifactRoot}/bin/pgmeta`); + expect(def.args).toBeUndefined(); + expect(def.env).toMatchObject({ + PG_META_HOST: "127.0.0.1", + PG_META_PORT: "54336", + PG_META_DB_HOST: "127.0.0.1", + PG_META_DB_PORT: String(DB_PORT), + }); + expect(def.healthCheck?.probe).toEqual({ + _tag: "Http", + host: "127.0.0.1", + port: 54336, + path: "/health", + scheme: "http", + }); + }); + + it("starts Studio from its published host launcher with host URLs", () => { + const artifactRoot = "/cache/studio/2026.08.17-sha-0c1da8f/darwin-arm64"; + const def = makeStudioServiceNative({ + binPath: artifactRoot, + port: 54323, + apiUrl: "http://127.0.0.1:54321", + publicApiUrl: "http://127.0.0.1:54321", + pgmetaUrl: "http://127.0.0.1:54336", + publishableKey: "sb_publishable_native", + secretKey: "sb_secret_native", + s3ProtocolAccessKeyId: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + s3ProtocolAccessKeySecret: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, + jwtSecret: JWT_SECRET, + analyticsEnabled: true, + analyticsBackend: "postgres", + analyticsUrl: "http://127.0.0.1:54327", + analyticsApiKey: "native-analytics-key", + dependencies: [{ service: "pgmeta", condition: "healthy" }], + }); + + expect(def.command).toBe(`${artifactRoot}/bin/studio`); + expect(def.args).toBeUndefined(); + expect(def.env).toMatchObject({ + PORT: "54323", + SUPABASE_URL: "http://127.0.0.1:54321", + SUPABASE_PUBLIC_URL: "http://127.0.0.1:54321", + STUDIO_PG_META_URL: "http://127.0.0.1:54336", + LOGFLARE_URL: "http://127.0.0.1:54327", + LOGFLARE_PRIVATE_ACCESS_TOKEN: "native-analytics-key", + HOSTNAME: "127.0.0.1", + }); + }); + + it("binds Mailpit protocols on independent loopback ports and an owning data path", () => { + const artifactRoot = "/cache/mailpit/v1.30.2/linux-amd64"; + const dataDir = "/tmp/stacks/project-a/data/mailpit"; + const def = makeMailpitServiceNative({ + binPath: artifactRoot, + dataDir, + webPort: 54323, + smtpPort: 54324, + pop3Port: 54325, + dependencies: [], + }); + + expect(def.command).toBe(`${artifactRoot}/bin/mailpit`); + expect(def.args).toBeUndefined(); + expect(def.env).toMatchObject({ + MP_UI_BIND_ADDR: "127.0.0.1:54323", + MP_SMTP_BIND_ADDR: "127.0.0.1:54324", + MP_POP3_BIND_ADDR: "127.0.0.1:54325", + MP_SMTP_DISABLE_RDNS: "true", + MP_DATABASE: `${dataDir}/mailpit.db`, + }); + expect(def.healthCheck?.probe).toEqual({ + _tag: "Http", + host: "127.0.0.1", + port: 54323, + path: "/readyz", + scheme: "http", + }); + }); + + it("cleans up only auto-managed native Mailpit data after an orphaned supervisor", () => { + const dataDir = "/tmp/stacks/project-a/data/mailpit"; + const managed = makeMailpitServiceNative({ + binPath: "/cache/mailpit/v1.30.2/linux-amd64", + dataDir, + webPort: 54323, + smtpPort: 54324, + pop3Port: 54325, + cleanupDataDirOnExit: true, + dependencies: [], + }); + const explicit = makeMailpitServiceNative({ + binPath: "/cache/mailpit/v1.30.2/linux-amd64", + dataDir, + webPort: 54323, + smtpPort: 54324, + pop3Port: 54325, + cleanupDataDirOnExit: false, + dependencies: [], + }); + + expect(managed.supervision?.orphanCleanup).toEqual([ + { _tag: "RemovePath", path: dataDir, recursive: true }, + ]); + expect(explicit.supervision?.orphanCleanup).toEqual([]); + }); + + it("couples native Storage and imgproxy on one owning data root", () => { + const artifactRoot = "/cache/storage/v1.70.1/darwin-arm64"; + const imgproxyArtifactRoot = "/cache/imgproxy/v3.8.0/darwin-arm64"; + const dataDir = "/tmp/stacks/project-a/data/storage"; + const storageDependencies = [{ service: "postgres-init", condition: "completed" }] as const; + const storage = makeStorageServiceNative({ + binPath: artifactRoot, + port: 54331, + dbPort: DB_PORT, + dataDir, + anonKey: "native-anon-key", + serviceKey: "native-service-key", + jwtSecret: JWT_SECRET, + jwtJwks: "native-jwks", + fileSizeLimit: "50MiB", + enableImageTransformation: true, + imgproxyUrl: "http://127.0.0.1:54332", + s3ProtocolEnabled: true, + cleanupDataDirOnExit: true, + dependencies: storageDependencies, + }); + const imgproxy = makeImgproxyServiceNative({ + binPath: imgproxyArtifactRoot, + port: 54332, + dataDir: `${dataDir}/`, + dependencies: [{ service: "storage", condition: "healthy" }], + }); + + expect(storage).toMatchObject({ + name: "storage", + command: `${artifactRoot}/bin/storage`, + dependencies: storageDependencies, + restart: "unless-stopped", + healthCheck: { + probe: { + _tag: "Http", + host: "127.0.0.1", + port: 54331, + path: "/status", + scheme: "http", + }, + }, + supervision: { + orphanCleanup: [{ _tag: "RemovePath", path: dataDir, recursive: true }], + }, + }); + expect(storage.env).toMatchObject({ + SERVER_HOST: "127.0.0.1", + SERVER_PORT: "54331", + DATABASE_URL: `postgresql://supabase_storage_admin:postgres@127.0.0.1:${DB_PORT}/postgres`, + FILE_STORAGE_BACKEND_PATH: dataDir, + STORAGE_FILE_BACKEND_PATH: dataDir, + ENABLE_IMAGE_TRANSFORMATION: "true", + IMAGE_TRANSFORMATION_ENABLED: "true", + IMGPROXY_URL: "http://127.0.0.1:54332", + FILE_SIZE_LIMIT: "50MiB", + S3_PROTOCOL_ENABLED: "true", + }); + expect(imgproxy).toMatchObject({ + name: "imgproxy", + command: `${imgproxyArtifactRoot}/bin/imgproxy`, + dependencies: [{ service: "storage", condition: "healthy" }], + restart: "unless-stopped", + healthCheck: { + probe: { + _tag: "Http", + host: "127.0.0.1", + port: 54332, + path: "/health", + scheme: "http", + }, + }, + }); + expect(imgproxy.env).toMatchObject({ + IMGPROXY_BIND: "127.0.0.1:54332", + IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", + IMGPROXY_ALLOWED_SOURCES: "local:////tmp/stacks/project-a/data/storage/", + IMGPROXY_USE_ETAG: "/", + }); + expect(imgproxy.env?.IMGPROXY_LOCAL_FILESYSTEM_ROOT).toBe("/"); + expect(imgproxy).not.toHaveProperty("supervision.orphanCleanup"); + }); }); describe("makePostgresInitService", () => { @@ -471,6 +810,7 @@ describe("docker-backed auxiliary services", () => { expect(def.args).toContain(`supabase-realtime-${API_PORT}`); expect(def.args).toContain("54330:54330"); expect(def.env?.DB_HOST).toBe("host.docker.internal"); + expect(def.env?.DB_USER).toBe("postgres"); expect(def.dependencies).toEqual(dependencies); expect(def.healthCheck?.probe).toEqual( expect.objectContaining({ _tag: "Exec", command: "curl" }), @@ -630,6 +970,7 @@ describe("docker-backed auxiliary services", () => { expect(def.healthCheck?.initialDelaySeconds).toBe(10); expect(def.env?.PORT).toBe("4000"); expect(def.env?.PHX_HTTP_PORT).toBe("4000"); + expect(def.env?.PHX_HTTP_IP).toBe("0.0.0.0"); expect(def.env?.LOGFLARE_NODE_HOST).toBe("0.0.0.0"); expect(args).toContain("54328:4000"); }); @@ -650,18 +991,20 @@ describe("docker-backed auxiliary services", () => { expect(def.env?.PORT).toBe("4000"); expect(def.env?.PHX_HTTP_PORT).toBe("4000"); + expect(def.env?.PHX_HTTP_IP).toBe("0.0.0.0"); expect(def.env?.LOGFLARE_NODE_HOST).toBe("0.0.0.0"); expect(def.args).toContain("host.docker.internal:host-gateway"); expect(def.args).toContain("54328:4000"); }); - it("keeps pooler container ports fixed and maps only the selected proxy port outward", () => { + it("keeps pooler container ports fixed and maps both proxy ports outward", () => { const def = makePoolerServiceDocker({ runtime: "docker", image: dockerImageForService("pooler", DEFAULT_VERSIONS.pooler), identity: EPHEMERAL_IDENTITY, hostAdminPort: 54329, - hostPort: 54330, + hostSessionPort: 54330, + hostTransactionPort: 54331, platformOs: "linux", dbHost: "127.0.0.1", dbPort: DB_PORT, @@ -686,6 +1029,7 @@ describe("docker-backed auxiliary services", () => { expect(def.env?.PROXY_PORT_SESSION).toBe(String(poolerContainerPorts.session)); expect(def.env?.PROXY_PORT_TRANSACTION).toBe(String(poolerContainerPorts.transaction)); expect(def.args).toContain(`54329:${poolerContainerPorts.admin}`); - expect(def.args).toContain(`54330:${poolerContainerPorts.transaction}`); + expect(def.args).toContain(`54330:${poolerContainerPorts.session}`); + expect(def.args).toContain(`54331:${poolerContainerPorts.transaction}`); }); }); diff --git a/packages/stack/src/services/storage.ts b/packages/stack/src/services/storage.ts index cf3914c720..4f6d51562b 100644 --- a/packages/stack/src/services/storage.ts +++ b/packages/stack/src/services/storage.ts @@ -4,16 +4,14 @@ import type { StackIdentity } from "../StackIdentity.ts"; import { removePathOnOrphanCleanup } from "./docker-cleanup.ts"; import { dockerRunService, + nativeRunService, type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerStorageOptions extends ContainerRuntimeOptions { - readonly image: string; +interface StorageServiceOptions { readonly port: number; - readonly identity: StackIdentity; - readonly dbHost: string; readonly dbPort: number; readonly dataDir: string; readonly anonKey: string; @@ -24,8 +22,19 @@ interface DockerStorageOptions extends ContainerRuntimeOptions { readonly enableImageTransformation: boolean; readonly imgproxyUrl: string; readonly s3ProtocolEnabled: boolean; - readonly platformOs: string; readonly dependencies: ReadonlyArray; +} + +export interface NativeStorageOptions extends StorageServiceOptions { + readonly binPath: string; + readonly cleanupDataDirOnExit?: boolean; +} + +interface DockerStorageOptions extends StorageServiceOptions, ContainerRuntimeOptions { + readonly image: string; + readonly identity: StackIdentity; + readonly dbHost: string; + readonly platformOs: string; readonly cleanupDataDirOnExit?: boolean; } @@ -34,7 +43,9 @@ const STORAGE_DATA_DIR = "/var/lib/storage"; export const LOCAL_S3_PROTOCOL_ACCESS_KEY_ID = "local"; export const LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET = "local-secret"; -const orphanCleanup = (opts: DockerStorageOptions) => +const orphanCleanup = ( + opts: Pick & { cleanupDataDirOnExit?: boolean }, +) => opts.cleanupDataDirOnExit ? removePathOnOrphanCleanup(opts.dataDir, { recursive: true }) : []; const storageHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ @@ -48,6 +59,46 @@ const storageHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ ...stackHealthBudgets.storage, }); +const storageNativeEnv = (opts: NativeStorageOptions): Record => ({ + SERVER_HOST: "127.0.0.1", + SERVER_PORT: String(opts.port), + ANON_KEY: opts.anonKey, + SERVICE_KEY: opts.serviceKey, + AUTH_JWT_SECRET: opts.jwtSecret, + PGRST_JWT_SECRET: opts.jwtSecret, + JWT_JWKS: opts.jwtJwks, + DATABASE_URL: `postgresql://supabase_storage_admin:postgres@127.0.0.1:${opts.dbPort}/postgres`, + FILE_SIZE_LIMIT: opts.fileSizeLimit, + STORAGE_BACKEND: "file", + FILE_STORAGE_BACKEND_PATH: opts.dataDir, + STORAGE_FILE_BACKEND_PATH: opts.dataDir, + TENANT_ID: "stub", + STORAGE_S3_REGION: "local", + GLOBAL_S3_BUCKET: "stub", + ENABLE_IMAGE_TRANSFORMATION: String(opts.enableImageTransformation), + IMAGE_TRANSFORMATION_ENABLED: String(opts.enableImageTransformation), + IMGPROXY_URL: opts.imgproxyUrl, + TUS_URL_PATH: "/storage/v1/upload/resumable", + S3_PROTOCOL_ENABLED: String(opts.s3ProtocolEnabled), + S3_PROTOCOL_ACCESS_KEY_ID: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + S3_PROTOCOL_ACCESS_KEY_SECRET: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, + S3_PROTOCOL_PREFIX: "/storage/v1", + UPLOAD_FILE_SIZE_LIMIT: "52428800000", + UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", + SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", +}); + +export const makeStorageServiceNative = (opts: NativeStorageOptions): ServiceDef => ({ + ...nativeRunService({ + name: "storage", + command: `${opts.binPath}/bin/storage`, + env: storageNativeEnv(opts), + dependencies: opts.dependencies, + healthCheck: storageHealthCheck(opts.port), + }), + supervision: { orphanCleanup: orphanCleanup(opts) }, +}); + export const makeStorageServiceDocker = (opts: DockerStorageOptions): ServiceDef => dockerRunService({ runtime: opts.runtime, diff --git a/packages/stack/src/services/studio.ts b/packages/stack/src/services/studio.ts index bb5f1009d5..9d5901a2d8 100644 --- a/packages/stack/src/services/studio.ts +++ b/packages/stack/src/services/studio.ts @@ -3,14 +3,13 @@ import { dockerNetworkArgs } from "../Platform.ts"; import type { StackIdentity } from "../StackIdentity.ts"; import { dockerRunService, + nativeRunService, type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; -interface DockerStudioOptions extends ContainerRuntimeOptions { - readonly image: string; - readonly identity: StackIdentity; +interface StudioServiceOptions { readonly port: number; readonly apiUrl: string; readonly publicApiUrl: string; @@ -24,10 +23,48 @@ interface DockerStudioOptions extends ContainerRuntimeOptions { readonly analyticsBackend: "postgres" | "bigquery"; readonly analyticsUrl: string; readonly analyticsApiKey: string; - readonly platformOs: string; readonly dependencies: ReadonlyArray; } +export interface NativeStudioOptions extends StudioServiceOptions { + readonly binPath: string; +} + +interface DockerStudioOptions extends StudioServiceOptions, ContainerRuntimeOptions { + readonly image: string; + readonly identity: StackIdentity; + readonly platformOs: string; +} + +const studioEnv = ( + opts: StudioServiceOptions, + host: "127.0.0.1" | "0.0.0.0", +): Record => ({ + PORT: String(opts.port), + CURRENT_CLI_VERSION: "local", + STUDIO_PG_META_URL: opts.pgmetaUrl, + POSTGRES_PASSWORD: "postgres", + SUPABASE_URL: opts.apiUrl, + SUPABASE_PUBLIC_URL: opts.publicApiUrl, + AUTH_JWT_SECRET: opts.jwtSecret, + SUPABASE_ANON_KEY: opts.publishableKey, + SUPABASE_SERVICE_KEY: opts.secretKey, + SUPABASE_PUBLISHABLE_KEY: opts.publishableKey, + SUPABASE_SECRET_KEY: opts.secretKey, + S3_PROTOCOL_ACCESS_KEY_ID: opts.s3ProtocolAccessKeyId, + S3_PROTOCOL_ACCESS_KEY_SECRET: opts.s3ProtocolAccessKeySecret, + LOGFLARE_PRIVATE_ACCESS_TOKEN: opts.analyticsApiKey, + LOGFLARE_URL: opts.analyticsUrl, + NEXT_PUBLIC_ENABLE_LOGS: String(opts.analyticsEnabled), + NEXT_ANALYTICS_BACKEND_PROVIDER: opts.analyticsBackend, + HOSTNAME: host, + POSTGRES_USER_READ_WRITE: "postgres", + OPENAI_API_KEY: "", + PGRST_DB_SCHEMAS: "public,graphql_public", + PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions", + PGRST_DB_MAX_ROWS: "1000", +}); + const studioHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ probe: { _tag: "Http", @@ -46,31 +83,16 @@ export const makeStudioServiceDocker = (opts: DockerStudioOptions): ServiceDef = identity: opts.identity, image: opts.image, networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), - env: { - PORT: String(opts.port), - CURRENT_CLI_VERSION: "local", - STUDIO_PG_META_URL: opts.pgmetaUrl, - POSTGRES_PASSWORD: "postgres", - SUPABASE_URL: opts.apiUrl, - SUPABASE_PUBLIC_URL: opts.publicApiUrl, - AUTH_JWT_SECRET: opts.jwtSecret, - SUPABASE_ANON_KEY: opts.publishableKey, - SUPABASE_SERVICE_KEY: opts.secretKey, - SUPABASE_PUBLISHABLE_KEY: opts.publishableKey, - SUPABASE_SECRET_KEY: opts.secretKey, - S3_PROTOCOL_ACCESS_KEY_ID: opts.s3ProtocolAccessKeyId, - S3_PROTOCOL_ACCESS_KEY_SECRET: opts.s3ProtocolAccessKeySecret, - LOGFLARE_PRIVATE_ACCESS_TOKEN: opts.analyticsApiKey, - LOGFLARE_URL: opts.analyticsUrl, - NEXT_PUBLIC_ENABLE_LOGS: String(opts.analyticsEnabled), - NEXT_ANALYTICS_BACKEND_PROVIDER: opts.analyticsBackend, - HOSTNAME: "0.0.0.0", - POSTGRES_USER_READ_WRITE: "postgres", - OPENAI_API_KEY: "", - PGRST_DB_SCHEMAS: "public,graphql_public", - PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions", - PGRST_DB_MAX_ROWS: "1000", - }, + env: studioEnv(opts, "0.0.0.0"), + dependencies: opts.dependencies, + healthCheck: studioHealthCheck(opts.port), + }); + +export const makeStudioServiceNative = (opts: NativeStudioOptions): ServiceDef => + nativeRunService({ + name: "studio", + command: `${opts.binPath}/bin/studio`, + env: studioEnv(opts, "127.0.0.1"), dependencies: opts.dependencies, healthCheck: studioHealthCheck(opts.port), }); diff --git a/packages/stack/src/services/vector.native.integration.test.ts b/packages/stack/src/services/vector.native.integration.test.ts new file mode 100644 index 0000000000..2e632088f2 --- /dev/null +++ b/packages/stack/src/services/vector.native.integration.test.ts @@ -0,0 +1,55 @@ +// oxlint-disable effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json -- Generated Vector configuration is a host-owned YAML boundary inspected as text. + +import { NodeFileSystem } from "@effect/platform-node"; +import { it } from "@effect/vitest"; +import { Effect, FileSystem, Layer } from "effect"; +import { describe, expect } from "vitest"; +import { NATIVE_LOG_SEGMENTS, nativeServiceLogSegmentPaths } from "../NativeLogWriter.ts"; +import { makeVectorServiceNative, prepareVectorConfig } from "./vector.ts"; + +describe("prepareVectorConfig", () => { + it.effect("writes an isolated owning-stack source and sink configuration", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const runtimeRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-vector-native-" }); + const prepared = yield* prepareVectorConfig({ + runtimeRoot, + adminPort: 54333, + analyticsPort: 54327, + analyticsApiKey: "analytics-key", + }); + const config = yield* fs.readFileString(prepared.configPath); + const dataDirectoryExists = yield* fs.exists(prepared.dataDir); + + expect(prepared.configPath).toBe(`${runtimeRoot}/vector/vector.yaml`); + expect(prepared.dataDir).toBe(`${runtimeRoot}/vector/data_dir`); + expect(dataDirectoryExists).toBe(true); + for (let index = 0; index < NATIVE_LOG_SEGMENTS; index += 1) { + expect(config).toContain( + `- "${runtimeRoot}/logs/*.jsonl${index === 0 ? "" : `.${index}`}"`, + ); + } + for (const path of nativeServiceLogSegmentPaths(runtimeRoot, "vector")) { + expect(config).toContain(`- "${path}"`); + } + expect(config).toContain(" . = parse_json!(.message)"); + expect(config).not.toContain(" decoding:"); + expect(config).toContain(" - parse_native_logs\n encoding:\n codec: json"); + expect(config).toContain('uri: "http://127.0.0.1:54327/api/logs?source_name=postgres.logs"'); + expect(config).toContain('x-api-key: "analytics-key"'); + expect(config).toContain(`data_dir: "${runtimeRoot}/vector/data_dir"`); + expect(config).toContain('address: "127.0.0.1:54333"'); + expect(config).not.toContain("docker_logs"); + + const definition = makeVectorServiceNative({ + binPath: "/cache/vector/0.53.0/darwin-arm64", + runtimeRoot, + adminPort: 54333, + analyticsPort: 54327, + analyticsApiKey: "analytics-key", + dependencies: [], + }); + expect(definition.args).toEqual(["--config", prepared.configPath]); + }).pipe(Effect.provide(Layer.mergeAll(NodeFileSystem.layer))), + ); +}); diff --git a/packages/stack/src/services/vector.native.unit.test.ts b/packages/stack/src/services/vector.native.unit.test.ts new file mode 100644 index 0000000000..add4aa8265 --- /dev/null +++ b/packages/stack/src/services/vector.native.unit.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { makeVectorServiceNative } from "./vector.ts"; + +describe("makeVectorServiceNative", () => { + it("launches the published binary with a private generated config and loopback health", () => { + const def = makeVectorServiceNative({ + binPath: "/cache/vector/0.53.0/darwin-arm64", + runtimeRoot: "/tmp/stacks/project-a/runtime", + adminPort: 54333, + analyticsPort: 54327, + analyticsApiKey: "analytics-key", + dependencies: [{ service: "analytics", condition: "healthy" }], + }); + + expect(def).toMatchObject({ + name: "vector", + command: "/cache/vector/0.53.0/darwin-arm64/bin/vector", + args: ["--config", "/tmp/stacks/project-a/runtime/vector/vector.yaml"], + dependencies: [{ service: "analytics", condition: "healthy" }], + restart: "unless-stopped", + }); + expect(def.env).toEqual({ VECTOR_THREADS: "1" }); + expect(def.healthCheck?.probe).toEqual({ + _tag: "Http", + host: "127.0.0.1", + port: 54333, + path: "/health", + scheme: "http", + }); + }); +}); diff --git a/packages/stack/src/services/vector.ts b/packages/stack/src/services/vector.ts index a4d15fb198..7c32f8702c 100644 --- a/packages/stack/src/services/vector.ts +++ b/packages/stack/src/services/vector.ts @@ -1,12 +1,23 @@ // oxlint-disable effecttsgo/node-builtin-import, effecttsgo/process-env -- Synchronous Docker socket discovery runs before Effect runtime construction and preserves the service's sync API. import { accessSync, constants } from "node:fs"; +import { join } from "node:path"; import { dockerNetworkArgs } from "../Platform.ts"; import type { ContainerRuntime } from "../ContainerRuntime.ts"; import { dockerContainerName, type StackIdentity } from "../StackIdentity.ts"; +import { Effect, FileSystem } from "effect"; +import { StackBuildError } from "../errors.ts"; +import { + NATIVE_LOG_SEGMENTS, + nativeLogRoot, + nativeServiceLogSegmentPaths, +} from "../NativeLogWriter.ts"; +import { prepareNativeDirectory, writeNativeFile } from "../native-filesystem.ts"; import { dockerExecHealthCheck, dockerRunService, + hostHttpHealthCheck, + nativeRunService, type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; @@ -22,6 +33,28 @@ interface DockerVectorOptions extends ContainerRuntimeOptions { readonly dependencies: ReadonlyArray; } +export interface NativeVectorOptions { + readonly binPath: string; + readonly runtimeRoot: string; + /** Port for Vector's private administrative HTTP API and health endpoint. */ + readonly adminPort: number; + readonly analyticsPort: number; + readonly analyticsApiKey: string; + readonly dependencies: ReadonlyArray; +} + +export interface VectorConfigPreparationOptions { + readonly runtimeRoot: string; + readonly adminPort: number; + readonly analyticsPort: number; + readonly analyticsApiKey: string; +} + +export interface PreparedVectorConfig { + readonly configPath: string; + readonly dataDir: string; +} + const vectorConfig = ( host: string, port: number, @@ -50,6 +83,86 @@ sinks: uri: "http://${host}:${port}/api/logs?source_name=docker.logs.local" `; +const yamlString = (value: string): string => JSON.stringify(value); + +const nativeVectorConfig = (opts: VectorConfigPreparationOptions): string => { + // The seeded single-tenant Logflare source is postgres.logs. Keep native + // ingestion on that source so the Analytics release accepts the payload; + // each stack owns an isolated Analytics database and source backend. + const logRoot = nativeLogRoot(opts.runtimeRoot); + const include = Array.from( + { length: NATIVE_LOG_SEGMENTS }, + (_, index) => `${logRoot}/*.jsonl${index === 0 ? "" : `.${index}`}`, + ); + const exclude = nativeServiceLogSegmentPaths(opts.runtimeRoot, "vector"); + + return `data_dir: ${yamlString(join(opts.runtimeRoot, "vector", "data_dir"))} + +api: + enabled: true + address: ${yamlString(`127.0.0.1:${opts.adminPort}`)} + +sources: + native_logs: + type: file + include: +${include.map((path) => ` - ${yamlString(path)}`).join("\n")} + exclude: +${exclude.map((path) => ` - ${yamlString(path)}`).join("\n")} + read_from: beginning + +transforms: + parse_native_logs: + type: remap + inputs: + - native_logs + source: | + . = parse_json!(.message) + +sinks: + logflare: + type: http + inputs: + - parse_native_logs + encoding: + codec: json + method: post + request: + retry_max_duration_secs: 10 + headers: + x-api-key: ${yamlString(opts.analyticsApiKey)} + uri: ${yamlString(`http://127.0.0.1:${opts.analyticsPort}/api/logs?source_name=postgres.logs`)} +`; +}; + +const vectorRuntimePaths = (runtimeRoot: string): PreparedVectorConfig => ({ + configPath: join(runtimeRoot, "vector", "vector.yaml"), + dataDir: join(runtimeRoot, "vector", "data_dir"), +}); + +/** Materialize Vector's per-stack config and checkpoint directory. */ +export const prepareVectorConfig = ( + opts: VectorConfigPreparationOptions, +): Effect.Effect => + Effect.gen(function* () { + const paths = vectorRuntimePaths(opts.runtimeRoot); + yield* prepareNativeDirectory( + join(opts.runtimeRoot, "vector"), + "Failed to create the native Vector runtime directory", + ); + yield* prepareNativeDirectory( + paths.dataDir, + "Failed to create the native Vector checkpoint directory", + ); + yield* writeNativeFile( + paths.configPath, + nativeVectorConfig(opts), + "Failed to write the native Vector configuration", + { flag: "w", mode: 0o600 }, + ); + return paths; + }); + const canAccessSocket = (socket: string): boolean => { try { accessSync(socket, constants.R_OK | constants.W_OK); @@ -129,3 +242,17 @@ ${vectorConfig( ), }); }; + +export const makeVectorServiceNative = (opts: NativeVectorOptions) => { + const paths = vectorRuntimePaths(opts.runtimeRoot); + return nativeRunService({ + name: "vector", + command: `${opts.binPath}/bin/vector`, + args: ["--config", paths.configPath], + env: { VECTOR_THREADS: "1" }, + dependencies: opts.dependencies, + healthCheck: hostHttpHealthCheck(opts.adminPort, "/health", { + ...stackHealthBudgets.vector, + }), + }); +}; diff --git a/packages/stack/src/stackHandle.ts b/packages/stack/src/stackHandle.ts index 3a9131fc9f..c569bd5289 100644 --- a/packages/stack/src/stackHandle.ts +++ b/packages/stack/src/stackHandle.ts @@ -10,6 +10,7 @@ import type { StackServiceState } from "./StackServiceState.ts"; export interface StackHandle extends AsyncDisposable { readonly url: string; readonly dbUrl: string; + readonly serviceEndpoints: Readonly>; readonly publishableKey: string; readonly secretKey: string; start(): Promise; @@ -33,6 +34,7 @@ export interface StackHandle extends AsyncDisposable { export const toStackHandle = (handle: ForegroundStackHandle): StackHandle => ({ url: handle.url, dbUrl: handle.dbUrl, + serviceEndpoints: handle.serviceEndpoints, publishableKey: handle.publishableKey, secretKey: handle.secretKey, start: () => Effect.runPromise(handle.start), diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts index cef123102e..dbf0b57d3a 100644 --- a/packages/stack/src/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor.integration.test.ts @@ -159,6 +159,7 @@ const messageFor = ( cwd: roots.root, projectDir: roots.root, mode: "native", + edgeRuntime: false, auth: false, postgrest: false, realtime: false, @@ -792,6 +793,7 @@ describe("detached supervisor child journeys", () => { { projectDir: roots.root, mode: "native", + edgeRuntime: false, auth: false, postgrest: false, realtime: false, @@ -921,7 +923,7 @@ describe("detached supervisor child journeys", () => { }; const persistedBefore = { ...before, - launch: { ...before.launch, excludedServices: ["analytics"] }, + launch: { ...before.launch, excludedServices: ["analytics", "pgmeta", "studio"] }, ports: [ ...before.ports, { key: "analytics.port", port: blockedAnalyticsPort, intent: "exact" }, @@ -1397,56 +1399,6 @@ describe("detached supervisor child journeys", () => { } }); - test("does not discard an explicit Edge Runtime request during native fallback", async () => { - const roots = await workspace(); - const binDir = mkdtempSync(join(tmpdir(), "sup-stack-native-edge-runtime-")); - for (const runtime of ["docker", "podman"]) { - const executable = join(binDir, runtime); - writeFileSync(executable, "#!/bin/sh\nexit 1\n"); - chmodSync(executable, 0o755); - } - const base = messageFor(roots); - const { mode: _mode, ...config } = base.config; - const child = spawnChild( - messageFor(roots, { config: { ...config, edgeRuntime: { inspectorPort: 8_123 } } }), - { environment: { PATH: `${binDir}:${process.env["PATH"] ?? ""}` } }, - ); - try { - await expect(child.started).rejects.toThrow("Native mode supports only"); - await waitForExit(child.child); - } finally { - if (child.child.exitCode === null) await kill(child.child); - cleanupRoots(roots); - rmSync(binDir, { recursive: true, force: true }); - } - }); - - test("does not discard an explicit Docker-only service request during native fallback", async () => { - const roots = await workspace(); - const binDir = mkdtempSync(join(tmpdir(), "sup-stack-native-storage-")); - for (const runtime of ["docker", "podman"]) { - const executable = join(binDir, runtime); - writeFileSync(executable, "#!/bin/sh\nexit 1\n"); - chmodSync(executable, 0o755); - } - const base = messageFor(roots); - const { mode: _mode, ...config } = base.config; - const child = spawnChild( - messageFor(roots, { - config: { ...config, storage: { dataDir: join(roots.root, "storage") } }, - }), - { environment: { PATH: `${binDir}:${process.env["PATH"] ?? ""}` } }, - ); - try { - await expect(child.started).rejects.toThrow("Native mode supports only"); - await waitForExit(child.child); - } finally { - if (child.child.exitCode === null) await kill(child.child); - cleanupRoots(roots); - rmSync(binDir, { recursive: true, force: true }); - } - }); - test("rejects an explicit mode change before attaching to a running owner", async () => { const roots = await workspace(); const binDir = mkdtempSync(join(tmpdir(), "sup-stack-mode-attach-")); diff --git a/packages/stack/src/supervisor.ts b/packages/stack/src/supervisor.ts index b76635d1f3..c8f42ac1c9 100644 --- a/packages/stack/src/supervisor.ts +++ b/packages/stack/src/supervisor.ts @@ -98,7 +98,7 @@ import { StackRpcTransportError, SupervisorStartError, } from "./errors.ts"; -import { runtimeSelectionForLaunch, applyNativeDefaults } from "./SupervisorUpgradeRestart.ts"; +import { runtimeSelectionForLaunch } from "./SupervisorUpgradeRestart.ts"; import type { SupervisorErrorMessage, SupervisorStartMessage, @@ -673,12 +673,8 @@ const makeRunManagedExecution = ( ownedPersistedRuntime === undefined ? yield* selectStackRuntime(requestedMode) : yield* validateStackRuntime(ownedPersistedRuntime); - const runtimeConfigInput = - runtime.mode === "native" && requestedMode === undefined - ? applyNativeDefaults(configInput) - : configInput; const activeFields = portFieldsForConfigInput({ - ...runtimeConfigInput, + ...configInput, mode: runtime.mode, }); const activeFieldSet = new Set(activeFields); @@ -689,7 +685,7 @@ const makeRunManagedExecution = ( (field) => PORT_CATALOG[field].persistence === "sticky" && !activeFieldSet.has(field), ), }; - yield* portRequestsForConfig(runtimeConfigInput, { runtime }); + yield* portRequestsForConfig(configInput, { runtime }); const launchInput = input.launch ?? { versions: {} }; const launch: ManagedStackLaunch = runtime.mode === "native" @@ -722,8 +718,8 @@ const makeRunManagedExecution = ( const managedPaths = yield* managedStackPathsEffect(input.stateRoot, started.stack.id); const resolved = yield* resolveConfig( { - ...runtimeConfigInput, - projectDir: runtimeConfigInput.projectDir ?? input.workspacePath, + ...configInput, + projectDir: configInput.projectDir ?? input.workspacePath, stackRoot: managedPaths.root, runtimeRoot: managedPaths.runtime, instanceId: started.stack.id, @@ -733,7 +729,7 @@ const makeRunManagedExecution = ( const config: ResolvedDaemonConfig = { ...resolved, name: input.stackName, - projectDir: runtimeConfigInput.projectDir ?? input.workspacePath, + projectDir: configInput.projectDir ?? input.workspacePath, }; yield* manager.recordLifecycle(ownership, { stackId: started.stack.id, diff --git a/packages/stack/src/version-plan.ts b/packages/stack/src/version-plan.ts index 7a487f2bb1..901f8e87c8 100644 --- a/packages/stack/src/version-plan.ts +++ b/packages/stack/src/version-plan.ts @@ -5,6 +5,7 @@ import { SERVICE_NAMES, type AvailableServiceVersionUpdate, type ServiceName, + type VersionRuntime, type VersionManifest, } from "./versions.ts"; @@ -15,8 +16,9 @@ export interface StackVersionOverride { } export interface StackVersionPlanInput { + readonly runtime?: VersionRuntime; readonly candidateBaseline?: Partial>; - readonly pinnedBaseline?: VersionManifest; + readonly pinnedBaseline?: Partial>; readonly localOverrides?: Partial>; readonly flagOverrides?: Partial>; } @@ -46,12 +48,17 @@ function fingerprintAvailableVersionUpdates( } export function planStackVersions(input: StackVersionPlanInput): StackVersionPlan { + const runtime = input.runtime ?? "native"; const candidateBaseline = fillServiceVersionManifest( - normalizeServiceVersions(input.candidateBaseline ?? {}), + normalizeServiceVersions(input.candidateBaseline ?? {}, runtime), + runtime, ); - const pinnedBaseline = input.pinnedBaseline ?? candidateBaseline; - const localOverrides = normalizeServiceVersions(input.localOverrides ?? {}); - const flagOverrides = normalizeServiceVersions(input.flagOverrides ?? {}); + const pinnedBaseline = fillServiceVersionManifest( + normalizeServiceVersions(input.pinnedBaseline ?? candidateBaseline, runtime), + runtime, + ); + const localOverrides = normalizeServiceVersions(input.localOverrides ?? {}, runtime); + const flagOverrides = normalizeServiceVersions(input.flagOverrides ?? {}, runtime); const activeOverrideMap = new Map(); for (const service of SERVICE_NAMES) { @@ -75,11 +82,14 @@ export function planStackVersions(input: StackVersionPlanInput): StackVersionPla } } - const runtimeVersions = fillServiceVersionManifest({ - ...pinnedBaseline, - ...localOverrides, - ...flagOverrides, - }); + const runtimeVersions = fillServiceVersionManifest( + { + ...pinnedBaseline, + ...localOverrides, + ...flagOverrides, + }, + runtime, + ); const availableUpdates = diffPinnedAndAvailableVersions(pinnedBaseline, candidateBaseline); return { diff --git a/packages/stack/src/version-plan.unit.test.ts b/packages/stack/src/version-plan.unit.test.ts index 474a52170b..bf47bc00f9 100644 --- a/packages/stack/src/version-plan.unit.test.ts +++ b/packages/stack/src/version-plan.unit.test.ts @@ -1,8 +1,25 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_VERSIONS } from "./versions.ts"; +import { DEFAULT_VERSIONS, DOCKER_DEFAULT_VERSIONS } from "./versions.ts"; import { planStackVersions } from "./version-plan.ts"; describe("planStackVersions", () => { + it("uses Docker defaults and normalization when planning a Docker runtime", () => { + expect( + planStackVersions({ + runtime: "docker", + flagOverrides: { pooler: "v2.9.7" }, + }), + ).toMatchObject({ + candidateBaseline: DOCKER_DEFAULT_VERSIONS, + pinnedBaseline: DOCKER_DEFAULT_VERSIONS, + runtimeVersions: { + ...DOCKER_DEFAULT_VERSIONS, + pooler: "2.9.7", + }, + activeOverrides: [{ service: "pooler", version: "2.9.7", source: "flag" }], + }); + }); + it("fills the candidate baseline from linked versions and defaults", () => { expect( planStackVersions({ @@ -78,16 +95,16 @@ describe("planStackVersions", () => { availableUpdates: [ { service: "auth", - pinnedVersion: "2.188.0-rc.15", + pinnedVersion: "v2.188.0-rc.15", availableVersion: "v2.188.1", }, { service: "storage", - pinnedVersion: "1.41.8", + pinnedVersion: "v1.41.8", availableVersion: "v1.43.3", }, ], - updateFingerprint: "auth:2.188.0-rc.15->v2.188.1|storage:1.41.8->v1.43.3", + updateFingerprint: "auth:v2.188.0-rc.15->v2.188.1|storage:v1.41.8->v1.43.3", }); }); }); diff --git a/packages/stack/src/versions.ts b/packages/stack/src/versions.ts index 66854fbc81..ae0315851b 100644 --- a/packages/stack/src/versions.ts +++ b/packages/stack/src/versions.ts @@ -1,5 +1,6 @@ import { DEFAULT_VERSIONS, + DOCKER_DEFAULT_VERSIONS, SERVICE_NAMES, dockerImageForArtifact, serviceMetadata, @@ -7,9 +8,11 @@ import { import type { ServiceName } from "./ServiceName.ts"; import { Schema } from "effect"; -export { DEFAULT_VERSIONS, SERVICE_NAMES } from "./ServiceCatalog.ts"; +export { DEFAULT_VERSIONS, DOCKER_DEFAULT_VERSIONS, SERVICE_NAMES } from "./ServiceCatalog.ts"; export type { ServiceName } from "./ServiceName.ts"; +export type VersionRuntime = "native" | "docker"; + export type VersionManifest = Readonly>; export const PartialVersionManifestSchema = Schema.Struct({ @@ -30,11 +33,16 @@ export const PartialVersionManifestSchema = Schema.Struct({ export type PartialVersionManifest = Schema.Schema.Type; +export const defaultVersionsForRuntime = ( + runtime: VersionRuntime, +): Readonly> => + runtime === "docker" ? DOCKER_DEFAULT_VERSIONS : DEFAULT_VERSIONS; + /** * Returns the full Docker image URL for a service. */ export function dockerImageForService(service: ServiceName, version: string): string { - return dockerImageForArtifact(service, normalizeServiceVersion(service, version)); + return dockerImageForArtifact(service, normalizeServiceVersion(service, version, "docker")); } function assertFullVersions( @@ -54,7 +62,11 @@ export function fullVersionManifest( } /** Normalizes a version string to the catalog's canonical stored form. */ -export function normalizeServiceVersion(service: ServiceName, version: string): string { +export function normalizeServiceVersion( + service: ServiceName, + version: string, + runtime: VersionRuntime = "native", +): string { const normalized = version.trim(); const metadata = serviceMetadata(service); const tagPrefix = metadata.artifact.docker.tagPrefix; @@ -63,7 +75,15 @@ export function normalizeServiceVersion(service: ServiceName, version: string): normalized.slice(0, tagPrefix.length).toLowerCase() === tagPrefix.toLowerCase() ? normalized.slice(tagPrefix.length) : normalized; - if (!metadata.defaultVersion.startsWith("v")) return withoutDockerTagPrefix; + // Explicit opaque identifiers (for example a commit label used by Studio) + // are already canonical and must not acquire a semantic-version prefix. + if (!/^[vV]?\d/.test(withoutDockerTagPrefix)) return withoutDockerTagPrefix; + const defaultVersion = metadata.defaultVersions[runtime]; + if (!defaultVersion.startsWith("v")) { + return withoutDockerTagPrefix.slice(0, 1).toLowerCase() === "v" + ? withoutDockerTagPrefix.slice(1) + : withoutDockerTagPrefix; + } return withoutDockerTagPrefix.slice(0, 1).toLowerCase() === "v" ? `v${withoutDockerTagPrefix.slice(1)}` : `v${withoutDockerTagPrefix}`; @@ -71,12 +91,13 @@ export function normalizeServiceVersion(service: ServiceName, version: string): export function normalizeServiceVersions( versions: Partial>, + runtime: VersionRuntime = "native", ): Partial { const normalized: Partial> = {}; for (const service of SERVICE_NAMES) { const version = versions[service]; if (typeof version === "string" && version.trim().length > 0) { - normalized[service] = normalizeServiceVersion(service, version); + normalized[service] = normalizeServiceVersion(service, version, runtime); } } return normalized; @@ -84,10 +105,12 @@ export function normalizeServiceVersions( export function fillServiceVersionManifest( versions: Partial>, + runtime: VersionRuntime = "native", ): VersionManifest { const filled: Partial> = {}; + const defaults = defaultVersionsForRuntime(runtime); for (const service of SERVICE_NAMES) { - filled[service] = versions[service] ?? DEFAULT_VERSIONS[service]; + filled[service] = versions[service] ?? defaults[service]; } return fullVersionManifest(filled); } diff --git a/packages/stack/src/versions.unit.test.ts b/packages/stack/src/versions.unit.test.ts index b87c39fbf2..49fb681092 100644 --- a/packages/stack/src/versions.unit.test.ts +++ b/packages/stack/src/versions.unit.test.ts @@ -5,14 +5,20 @@ import { } from "../scripts/sync-versions-from-dockerfile.ts"; import { DEFAULT_VERSIONS, + DOCKER_DEFAULT_VERSIONS, diffPinnedAndAvailableVersions, + defaultVersionsForRuntime, dockerImageForService, fillServiceVersionManifest, normalizeServiceVersion, SERVICE_NAMES, type VersionManifest, } from "./versions.ts"; -import { SERVICE_CATALOG } from "./ServiceCatalog.ts"; +import { + dockerImageForArtifact, + nativeReleaseForService, + SERVICE_CATALOG, +} from "./ServiceCatalog.ts"; const sampleDockerfile = ` FROM supabase/postgres:17.0.0.1 AS pg @@ -38,7 +44,7 @@ describe("syncDefaultVersionsSource", () => { (service) => ` ${JSON.stringify(service)}: { name: ${JSON.stringify(service)}, configKey: "example", - defaultVersion: "old", + defaultVersions: { native: "native-old", docker: "old" }, },`, ).join("\n"); @@ -48,15 +54,16 @@ describe("syncDefaultVersionsSource", () => { ); expect(updated).toContain( - 'name: "postgres",\n configKey: "example",\n defaultVersion: "17.0.0.1"', + 'name: "postgres",\n configKey: "example",\n defaultVersions: { native: "native-old", docker: "17.0.0.1" }', ); expect(updated).toContain( - 'name: "edge-runtime",\n configKey: "example",\n defaultVersion: "v1.70.0"', + 'name: "edge-runtime",\n configKey: "example",\n defaultVersions: { native: "native-old", docker: "v1.70.0" }', ); expect(updated).toContain( - 'name: "mailpit",\n configKey: "example",\n defaultVersion: "v1.2.3"', + 'name: "mailpit",\n configKey: "example",\n defaultVersions: { native: "native-old", docker: "v1.2.3" }', ); - expect(updated).not.toContain('defaultVersion: "old"'); + expect(updated).toContain('native: "native-old"'); + expect(updated).not.toContain('docker: "old"'); }); it("fails when a required Dockerfile image alias is missing", () => { @@ -110,11 +117,25 @@ describe("dockerImageForService", () => { }); it("uses the upstream mirror repositories for vector and pooler", () => { - expect(dockerImageForService("vector", DEFAULT_VERSIONS.vector)).toBe( - `ghcr.io/supabase/vector:${DEFAULT_VERSIONS.vector}`, + expect(dockerImageForService("vector", DOCKER_DEFAULT_VERSIONS.vector)).toBe( + `ghcr.io/supabase/vector:${DOCKER_DEFAULT_VERSIONS.vector}`, ); - expect(dockerImageForService("pooler", DEFAULT_VERSIONS.pooler)).toBe( - `ghcr.io/supabase/supavisor:${DEFAULT_VERSIONS.pooler}`, + expect(dockerImageForService("pooler", DOCKER_DEFAULT_VERSIONS.pooler)).toBe( + `ghcr.io/supabase/supavisor:${DOCKER_DEFAULT_VERSIONS.pooler}`, + ); + }); + + it("keeps runtime-specific defaults for native and Docker consumers", () => { + expect(defaultVersionsForRuntime("native")).toEqual(DEFAULT_VERSIONS); + expect(DOCKER_DEFAULT_VERSIONS.vector).toBe("0.53.0-alpine"); + expect(DOCKER_DEFAULT_VERSIONS.pooler).toBe("2.9.12"); + expect(DEFAULT_VERSIONS.vector).toBe("0.53.0"); + expect(DEFAULT_VERSIONS.pooler).toBe("v2.9.10"); + expect(dockerImageForService("vector", DOCKER_DEFAULT_VERSIONS.vector)).toBe( + "ghcr.io/supabase/vector:0.53.0-alpine", + ); + expect(dockerImageForService("pooler", DOCKER_DEFAULT_VERSIONS.pooler)).toBe( + "ghcr.io/supabase/supavisor:2.9.12", ); }); @@ -125,18 +146,66 @@ describe("dockerImageForService", () => { expect(dockerImageForService("pooler", "2.9.6")).toBe("ghcr.io/supabase/supavisor:2.9.6"); }); - it("keeps non-managed services Docker-only", () => { + it("publishes native slim-services artifacts for every service", () => { expect(SERVICE_CATALOG.imgproxy).toMatchObject({ - runtimeSupport: "docker-only", - artifact: { docker: { repository: "imgproxy" } }, + artifact: { docker: { repository: "imgproxy" }, native: expect.any(Object) }, }); expect(SERVICE_CATALOG.mailpit).toMatchObject({ - runtimeSupport: "docker-only", - artifact: { docker: { repository: "mailpit" } }, + artifact: { docker: { repository: "mailpit" }, native: expect.any(Object) }, }); expect(SERVICE_CATALOG.vector).toMatchObject({ - runtimeSupport: "docker-only", - artifact: { docker: { repository: "vector" } }, + artifact: { docker: { repository: "vector" }, native: expect.any(Object) }, + }); + }); + + it("publishes exact frozen versions through native and Docker consumers", () => { + const platform = { os: "linux", arch: "x64" } as const; + + expect(DEFAULT_VERSIONS.pgmeta).toBe("v0.98.0"); + expect(DEFAULT_VERSIONS.pooler).toBe("v2.9.10"); + expect(nativeReleaseForService("pgmeta", DEFAULT_VERSIONS.pgmeta, platform)).toMatchObject({ + releaseTag: "pgmeta-v0.98.0", + downloadUrl: + "https://github.com/supabase/slim-services/releases/download/pgmeta-v0.98.0/pgmeta-v0.98.0-linux-amd64.tar.zst", + }); + expect(nativeReleaseForService("pooler", DEFAULT_VERSIONS.pooler, platform)).toMatchObject({ + releaseTag: "pooler-v2.9.10", + downloadUrl: + "https://github.com/supabase/slim-services/releases/download/pooler-v2.9.10/pooler-v2.9.10-linux-amd64.tar.zst", + }); + }); + + it("uses the frozen Docker tags for the public versions", () => { + expect(dockerImageForArtifact("pgmeta", DOCKER_DEFAULT_VERSIONS.pgmeta)).toBe( + "ghcr.io/supabase/cli/pgmeta:v0.99.0", + ); + expect(dockerImageForArtifact("pooler", DOCKER_DEFAULT_VERSIONS.pooler)).toBe( + "ghcr.io/supabase/supavisor:2.9.12", + ); + }); + + it("publishes portable host launcher paths for native service artifacts", () => { + const platform = { os: "linux", arch: "x64" } as const; + + expect(nativeReleaseForService("realtime", DEFAULT_VERSIONS.realtime, platform)).toMatchObject({ + requiredRuntimePaths: ["bin/migrate", "bin/realtime", "bin/server"], + }); + expect(nativeReleaseForService("storage", DEFAULT_VERSIONS.storage, platform)).toMatchObject({ + requiredRuntimePaths: ["bin/storage"], + }); + expect(nativeReleaseForService("pgmeta", DEFAULT_VERSIONS.pgmeta, platform)).toMatchObject({ + requiredRuntimePaths: ["bin/pgmeta"], + }); + expect(nativeReleaseForService("studio", DEFAULT_VERSIONS.studio, platform)).toMatchObject({ + requiredRuntimePaths: ["bin/studio"], + }); + expect( + nativeReleaseForService("analytics", DEFAULT_VERSIONS.analytics, platform), + ).toMatchObject({ + requiredRuntimePaths: ["bin/logflare"], + }); + expect(nativeReleaseForService("pooler", DEFAULT_VERSIONS.pooler, platform)).toMatchObject({ + requiredRuntimePaths: ["bin/migrate", "bin/supavisor", "bin/server"], }); }); }); @@ -158,13 +227,18 @@ describe("normalizeServiceVersion", () => { expect(normalizeServiceVersion("postgres", "17.6.1.090")).toBe("17.6.1.090"); }); - it("normalizes a prefixed pgmeta override to its catalog tag", () => { - expect(normalizeServiceVersion("pgmeta", "v0.98.0")).toBe("0.98.0"); - expect(normalizeServiceVersion("pgmeta", "V0.98.0")).toBe("0.98.0"); + it("normalizes PgMeta overrides to its v-prefixed catalog tag", () => { + expect(normalizeServiceVersion("pgmeta", "v0.98.0")).toBe("v0.98.0"); + expect(normalizeServiceVersion("pgmeta", "V0.98.0")).toBe("v0.98.0"); expect(dockerImageForService("pgmeta", normalizeServiceVersion("pgmeta", "v0.98.0"))).toBe( "ghcr.io/supabase/cli/pgmeta:v0.98.0", ); }); + + it("normalizes Pooler overrides according to the selected runtime", () => { + expect(normalizeServiceVersion("pooler", "2.9.10", "native")).toBe("v2.9.10"); + expect(normalizeServiceVersion("pooler", "v2.9.7", "docker")).toBe("2.9.7"); + }); }); describe("fillServiceVersionManifest", () => { diff --git a/packages/stack/tests/createStack-docker.e2e.test.ts b/packages/stack/tests/createStack-docker.e2e.test.ts index bb6b191463..a88bcbdc47 100644 --- a/packages/stack/tests/createStack-docker.e2e.test.ts +++ b/packages/stack/tests/createStack-docker.e2e.test.ts @@ -10,7 +10,7 @@ import { dockerContainerName } from "../src/StackIdentity.ts"; import { createStack, type StackHandle } from "../src/node.ts"; import { dependencyTimeoutSecondsForServices } from "../src/services/health-budgets.ts"; import { - DEFAULT_VERSIONS, + DOCKER_DEFAULT_VERSIONS, SERVICE_NAMES, dockerImageForService, type ServiceName, @@ -269,7 +269,7 @@ dockerDescribe("createStack e2e (docker mode)", () => { const containerName = dockerContainerName(service, apiPort); const container = ownedContainers.find((candidate) => candidate.name === containerName); expect(container?.image, `${service} image`).toBe( - dockerImageForService(service, DEFAULT_VERSIONS[service]), + dockerImageForService(service, DOCKER_DEFAULT_VERSIONS[service]), ); } diff --git a/packages/stack/tests/createStack-native-services.e2e.test.ts b/packages/stack/tests/createStack-native-services.e2e.test.ts new file mode 100644 index 0000000000..447d96dc16 --- /dev/null +++ b/packages/stack/tests/createStack-native-services.e2e.test.ts @@ -0,0 +1,801 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-fetch, effecttsgo/global-timers, effecttsgo/new-promise, effecttsgo/node-builtin-import, effecttsgo/process-env -- Native e2e tests await subprocess-backed stack operations and inspect exact temporary roots. + +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createConnection, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + createStack, + prefetch, + type LogEntry, + type ResolvedFunctionsBundle, + type StackHandle, +} from "@supabase/stack"; +import { activationTimeoutSecondsForService } from "../src/ServiceActivation.ts"; +import { fetchFunctionWhenReady, setupTestTable } from "./helpers/e2e.ts"; + +const NATIVE_SERVICES = [ + "postgres", + "postgrest", + "auth", + "edge-runtime", + "realtime", + "storage", + "imgproxy", + "mailpit", + "pgmeta", + "studio", + "analytics", + "vector", + "pooler", +] as const; + +const JWT_SECRET = "native-services-e2e-jwt-secret-with-at-least-32-characters"; +const EDGE_LOG_MARKER = "native-services-vector-marker"; +const LAZY_REALTIME_CLIENT_MARGIN_MS = 15_000; +const LAZY_REALTIME_CLIENT_WINDOW_MS = + activationTimeoutSecondsForService("realtime") * 1000 + LAZY_REALTIME_CLIENT_MARGIN_MS; +const LAZY_REALTIME_CHANGE_GUARD_MS = LAZY_REALTIME_CLIENT_WINDOW_MS + 30_000; + +const tinyPng = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, + 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, + 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]); + +const codeSafeJson = (value: string): string => + JSON.stringify(value).replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029"); + +const writeFunction = (projectDir: string, slug: string, body: string): string => { + const entrypointPath = join(projectDir, "supabase", "functions", slug, "index.ts"); + mkdirSync(join(projectDir, "supabase", "functions", slug), { recursive: true }); + writeFileSync( + entrypointPath, + `Deno.serve(() => { console.log(${codeSafeJson(EDGE_LOG_MARKER)}); return new Response(${codeSafeJson(body)}); });\n`, + "utf8", + ); + return entrypointPath; +}; + +const functionsBundle = (projectDir: string): ResolvedFunctionsBundle => ({ + env: {}, + functions: [ + { + name: "native-services", + verifyJWT: false, + entrypointPath: writeFunction(projectDir, "native-services", "native services ready"), + importMapPath: null, + staticFiles: [], + env: {}, + }, + ], +}); + +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return typeof error === "object" && error !== null && Reflect.get(error, "code") !== "ESRCH"; + } +}; + +const stagingEntries = (root: string): ReadonlyArray => { + if (!existsSync(root)) return []; + const found: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.name.includes(".partial-") || entry.name.includes(".publication-lock")) + found.push(path); + if (entry.isDirectory()) found.push(...stagingEntries(path)); + } + return found; +}; + +interface SmtpSession { + readonly socket: Socket; + readonly nextResponse: () => Promise; +} + +const openSmtp = async (port: number): Promise => { + const socket = createConnection({ host: "127.0.0.1", port }); + let buffer = ""; + let responseLines: string[] = []; + let waiter: { resolve: (response: string) => void; reject: (error: Error) => void } | undefined; + + const parse = (): void => { + while (waiter !== undefined) { + const newline = buffer.indexOf("\r\n"); + if (newline < 0) return; + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 2); + responseLines.push(line); + if (/^\d{3} /.test(line)) { + const current = waiter; + waiter = undefined; + const response = responseLines.join("\n"); + responseLines = []; + current.resolve(response); + } + } + }; + + socket.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + parse(); + }); + const rejectPending = (error: Error): void => { + waiter?.reject(error); + waiter = undefined; + }; + socket.once("error", rejectPending); + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + + return { + socket, + nextResponse: () => + new Promise((resolve, reject) => { + waiter = { resolve, reject }; + parse(); + }), + }; +}; + +const smtpSend = async (port: number, from: string, to: string, subject: string): Promise => { + const session = await openSmtp(port); + const command = async (line: string): Promise => { + session.socket.write(`${line}\r\n`); + return session.nextResponse(); + }; + try { + expect((await session.nextResponse()).startsWith("220")).toBe(true); + expect((await command("EHLO localhost")).startsWith("250")).toBe(true); + expect((await command(`MAIL FROM:<${from}>`)).startsWith("250")).toBe(true); + expect((await command(`RCPT TO:<${to}>`)).startsWith("250")).toBe(true); + expect((await command("DATA")).startsWith("354")).toBe(true); + session.socket.write( + `From: ${from}\r\nTo: ${to}\r\nSubject: ${subject}\r\nContent-Type: text/plain\r\n\r\nnative services mail\r\n.\r\n`, + ); + expect((await session.nextResponse()).startsWith("250")).toBe(true); + expect((await command("QUIT")).startsWith("221")).toBe(true); + } finally { + session.socket.destroy(); + } +}; + +const readJson = async (response: Response): Promise => { + const text = await response.text(); + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +}; + +const endpointPort = (endpoint: string): number => { + const port = Number(new URL(endpoint).port); + if (!Number.isInteger(port) || port < 1) + throw new Error(`Endpoint has no usable port: ${endpoint}`); + return port; +}; + +const requiredEndpoint = (endpoints: Readonly>, name: string): string => { + const endpoint = endpoints[name]; + if (endpoint === undefined) throw new Error(`Published endpoint is missing: ${name}`); + return endpoint; +}; + +const withTimeout = async ( + promise: Promise, + phase: string, + timeoutMs = 60_000, +): Promise => { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`Timed out during ${phase} after ${timeoutMs}ms`)), + timeoutMs, + ); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +}; + +const DIAGNOSTIC_LOG_LIMIT = 20; +const DIAGNOSTIC_LINE_LIMIT = 2_000; +const REALTIME_CLIENT_LOG_LIMIT = 30; +const REALTIME_CLIENT_PAYLOAD_LIMIT = 400; + +interface JourneyDiagnostics { + readonly stop: () => Promise; + readonly format: () => string; +} + +interface RealtimeClientDiagnostics { + readonly log: (kind: string, message: string, data?: unknown) => void; + readonly format: () => string; +} + +const closeDiagnosticIterator = async (iterator: AsyncIterator): Promise => { + try { + await iterator.return?.(); + } catch (error) { + if (error instanceof Error && error.message === "All fibers interrupted without error") return; + throw error; + } +}; + +const REALTIME_SECRET_KEY = /authorization|access[_-]?token|api[_-]?key|apikey|jwt|secret|token/i; + +const redactRealtimeClientText = (value: string): string => + value + .replace( + /([?&](?:access_token|apikey|authorization|key|secret|token)=)[^&\s]*/gi, + "$1", + ) + .replace(/\bBearer\s+[A-Za-z0-9._~-]+/gi, "Bearer ") + .replace(/\bsb_(?:publishable|secret)_[A-Za-z0-9_-]+\b/g, "") + .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, ""); + +const redactRealtimeClientData = (value: unknown, depth = 0): unknown => { + if (depth > 4) return ""; + if (typeof value === "string") return redactRealtimeClientText(value); + if (Array.isArray(value)) return value.map((item) => redactRealtimeClientData(item, depth + 1)); + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + REALTIME_SECRET_KEY.test(key) ? "" : redactRealtimeClientData(item, depth + 1), + ]), + ); +}; + +const collectRealtimeClientDiagnostics = (): RealtimeClientDiagnostics => { + const entries: string[] = []; + return { + log: (kind, message, data) => { + let line = redactRealtimeClientText(`${kind}: ${message}`); + if (data !== undefined) { + try { + line += ` ${JSON.stringify(redactRealtimeClientData(data))}`; + } catch { + line += " "; + } + } + entries.push( + line.length > REALTIME_CLIENT_PAYLOAD_LIMIT + ? `${line.slice(0, REALTIME_CLIENT_PAYLOAD_LIMIT)}…` + : line, + ); + if (entries.length > REALTIME_CLIENT_LOG_LIMIT) entries.shift(); + }, + format: () => `realtime-client=${JSON.stringify(entries)}`, + }; +}; + +type JourneyServiceState = Awaited>[number]; + +const collectJourneyDiagnostics = (handle: StackHandle): JourneyDiagnostics => { + const states = new Map(); + const logs = new Map>(); + const statusIterator = handle.statusChanges()[Symbol.asyncIterator](); + const logIterator = handle.logs()[Symbol.asyncIterator](); + let stopPromise: Promise | undefined; + + const readStatuses = (async (): Promise => { + try { + for (;;) { + const next = await statusIterator.next(); + if (next.done) return; + states.set(next.value.name, next.value); + } + } catch { + // Disposal closes the public stream; diagnostics collected before then remain useful. + } + })(); + const readLogs = (async (): Promise => { + try { + for (;;) { + const next = await logIterator.next(); + if (next.done) return; + const entries = logs.get(next.value.service) ?? []; + entries.push({ + ...next.value, + line: + next.value.line.length > DIAGNOSTIC_LINE_LIMIT + ? `${next.value.line.slice(0, DIAGNOSTIC_LINE_LIMIT)}…` + : next.value.line, + }); + if (entries.length > DIAGNOSTIC_LOG_LIMIT) entries.shift(); + logs.set(next.value.service, entries); + } + } catch { + // Disposal closes the public stream; diagnostics collected before then remain useful. + } + })(); + + return { + stop: () => { + if (stopPromise === undefined) { + stopPromise = (async () => { + await Promise.all([ + closeDiagnosticIterator(statusIterator), + closeDiagnosticIterator(logIterator), + ]); + await Promise.all([readStatuses, readLogs]); + })(); + } + return stopPromise; + }, + format: () => { + const serviceLogs = [...logs.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .flatMap(([service, entries]) => + [...entries].reverse().map((entry) => `log[${service}]=${JSON.stringify(entry)}`), + ); + return [`status=${JSON.stringify([...states.values()])}`, ...serviceLogs].join("\n"); + }, + }; +}; + +describe("native remaining service graph", () => { + let stack: StackHandle; + let supabase: SupabaseClient; + let projectDir: string; + let dataDir: string; + let storageDataDir: string; + let cacheRoot: string; + let stackRoot: string; + let runtimeRoot: string; + let sentinelBin: string; + let sentinelMarker: string; + let originalPath: string | undefined; + let ownedPids: ReadonlyArray = []; + let journeyDiagnostics: JourneyDiagnostics | undefined; + let stackDisposed = false; + + const disposeStack = async (): Promise => { + if (stackDisposed || stack === undefined) return; + stackDisposed = true; + await stack.dispose(); + }; + + beforeAll(async () => { + projectDir = mkdtempSync(join(tmpdir(), "supabase-native-services-project-")); + dataDir = mkdtempSync(join(tmpdir(), "supabase-native-services-postgres-")); + storageDataDir = mkdtempSync(join(tmpdir(), "supabase-native-services-storage-")); + cacheRoot = mkdtempSync(join(tmpdir(), "supabase-native-services-cache-")); + stackRoot = mkdtempSync(join(tmpdir(), "supabase-native-services-stack-")); + runtimeRoot = mkdtempSync(join(tmpdir(), "supabase-native-services-runtime-")); + sentinelBin = mkdtempSync(join(tmpdir(), "supabase-native-services-sentinel-")); + sentinelMarker = join(sentinelBin, "invoked"); + originalPath = process.env.PATH; + for (const executable of ["docker", "podman"]) { + const path = join(sentinelBin, executable); + writeFileSync(path, `#!/bin/sh\nprintf '%s\\n' "$0" >> "${sentinelMarker}"\n`, "utf8"); + chmodSync(path, 0o755); + } + process.env.PATH = [sentinelBin, originalPath].filter((value) => value !== undefined).join(":"); + + await prefetch({ mode: "native", cacheRoot, services: NATIVE_SERVICES }); + stack = await createStack({ + mode: "native", + cacheRoot, + stackRoot, + runtimeRoot, + projectDir, + functions: functionsBundle(projectDir), + jwtSecret: JWT_SECRET, + readiness: { mode: "finite", timeoutMs: 900_000 }, + servicePolicies: { + postgres: "eager", + postgrest: "lazy", + auth: "lazy", + "edge-runtime": "lazy", + realtime: "lazy", + storage: "lazy", + imgproxy: "lazy", + mailpit: "eager", + pgmeta: "eager", + studio: "eager", + analytics: "eager", + vector: "eager", + pooler: "eager", + }, + postgres: { dataDir }, + postgrest: {}, + auth: {}, + edgeRuntime: {}, + realtime: {}, + storage: { dataDir: storageDataDir }, + imgproxy: {}, + mailpit: {}, + pgmeta: {}, + studio: {}, + analytics: { apiKey: "native-services-analytics-key" }, + vector: {}, + pooler: {}, + }); + + journeyDiagnostics = collectJourneyDiagnostics(stack); + try { + await stack.start(); + } catch (error) { + const diagnostics = journeyDiagnostics.format(); + await journeyDiagnostics.stop(); + await disposeStack().catch(() => {}); + throw new Error(`Native stack startup failed: ${String(error)}\n${diagnostics}`, { + cause: error, + }); + } + supabase = createClient(stack.url, stack.publishableKey); + await setupTestTable(Number(new URL(stack.dbUrl).port)); + }, 1_200_000); + + afterAll(async () => { + try { + await journeyDiagnostics?.stop(); + if (stack !== undefined) { + const states = await stack.getStatus().catch(() => []); + ownedPids = states.flatMap((state) => (state.pid === null ? [] : [state.pid])); + await disposeStack(); + for (const pid of ownedPids) expect(isProcessAlive(pid)).toBe(false); + } + expect(existsSync(sentinelMarker)).toBe(false); + expect(stagingEntries(cacheRoot)).toEqual([]); + expect(stagingEntries(stackRoot)).toEqual([]); + expect(stagingEntries(runtimeRoot)).toEqual([]); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + for (const root of [ + dataDir, + storageDataDir, + cacheRoot, + stackRoot, + runtimeRoot, + projectDir, + sentinelBin, + ]) { + if (root !== undefined) rmSync(root, { recursive: true, force: true }); + } + } + }, 180_000); + + test( + "qualifies the complete native graph through public Edge, Realtime, Storage, Analytics, Mailpit, and Pooler journeys", + { timeout: 1_200_000 }, + async () => { + let phase = "initial stack status"; + let realtimeClientDiagnostics: RealtimeClientDiagnostics | undefined; + try { + const initialStates = await stack.getStatus(); + expect(initialStates.map((state) => state.name).toSorted()).toEqual( + [...NATIVE_SERVICES].toSorted(), + ); + expect( + initialStates.filter((state) => state.status === "Healthy").map((state) => state.name), + ).toEqual( + expect.arrayContaining([ + "postgres", + "mailpit", + "pgmeta", + "studio", + "analytics", + "vector", + "pooler", + ]), + ); + expect(initialStates.find((state) => state.name === "realtime")?.status).toBe("Dormant"); + expect(existsSync(sentinelMarker)).toBe(false); + + phase = "Auth settings"; + const authSettings = await fetch(`${stack.url}/auth/v1/settings`, { + headers: { apikey: stack.publishableKey }, + }); + expect(authSettings.status).toBe(200); + await authSettings.arrayBuffer(); + + phase = "Edge Function invocation"; + const functionResponse = await fetchFunctionWhenReady( + `${stack.url}/functions/v1/native-services`, + ); + expect(functionResponse.status).toBe(200); + expect(await functionResponse.text()).toBe("native services ready"); + + phase = "PostgREST query"; + const postgrestTable = await supabase.from("todos").select("id").limit(1); + expect(postgrestTable.error).toBeNull(); + + phase = "Realtime publication setup"; + const sql = new Bun.SQL(stack.dbUrl); + try { + await sql.unsafe(` + DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'todos' + ) THEN + ALTER PUBLICATION supabase_realtime ADD TABLE public.todos; + END IF; + END $$; + `); + } finally { + await sql.close(); + } + + phase = "Realtime database change"; + realtimeClientDiagnostics = collectRealtimeClientDiagnostics(); + const realtime = createClient(stack.url, stack.publishableKey, { + realtime: { + timeout: LAZY_REALTIME_CLIENT_WINDOW_MS, + heartbeatIntervalMs: LAZY_REALTIME_CLIENT_WINDOW_MS, + logger: realtimeClientDiagnostics.log, + }, + }); + let realtimeChannel: ReturnType | undefined; + let insertStarted = false; + const realtimeChange = new Promise((resolve, reject) => { + const insert = () => { + if (insertStarted) return; + insertStarted = true; + const sql = new Bun.SQL(stack.dbUrl); + void sql + .unsafe( + `INSERT INTO public.todos (title, completed) VALUES ('native-realtime', false)`, + ) + .then(() => sql.close()) + .catch(async (error: unknown) => { + await sql.close(); + reject(error instanceof Error ? error : new Error(String(error))); + }); + }; + realtimeChannel = realtime + .channel("native-services-todos") + .on( + "postgres_changes", + { event: "INSERT", schema: "public", table: "todos" }, + (payload) => resolve(payload), + ) + .on( + "system", + {}, + (payload: { + readonly extension: string; + readonly status: string; + readonly message: string; + readonly channel: string; + }) => { + if (payload.status === "error") { + reject(new Error(`Realtime PostgreSQL readiness failed: ${payload.message}`)); + } else if (payload.extension === "postgres_changes" && payload.status === "ok") { + insert(); + } + }, + ) + .subscribe((status) => { + if (status !== "SUBSCRIBED") { + if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") { + reject(new Error(`Realtime subscription status: ${status}`)); + } + return; + } + }); + }); + try { + const change = await withTimeout( + realtimeChange, + "Realtime database change", + LAZY_REALTIME_CHANGE_GUARD_MS, + ); + expect(change).toEqual( + expect.objectContaining({ + eventType: "INSERT", + table: "todos", + schema: "public", + }), + ); + } finally { + if (realtimeChannel !== undefined) await realtime.removeChannel(realtimeChannel); + } + + phase = "Storage image journey"; + const bucket = `native-services-${Date.now()}`; + const storageAdmin = createClient(stack.url, stack.secretKey); + const createdBucket = await storageAdmin.storage.createBucket(bucket, { public: true }); + expect(createdBucket.error).toBeNull(); + const uploaded = await storageAdmin.storage + .from(bucket) + .upload("tiny.png", new Blob([tinyPng], { type: "image/png" }), { + contentType: "image/png", + upsert: true, + }); + expect(uploaded.error).toBeNull(); + const downloaded = await supabase.storage.from(bucket).download("tiny.png"); + expect(downloaded.error).toBeNull(); + expect(downloaded.data).not.toBeNull(); + expect(new Uint8Array(await downloaded.data!.arrayBuffer())).toEqual(tinyPng); + const transformed = await fetch( + `${stack.url}/storage/v1/render/image/public/${bucket}/tiny.png?width=2&height=2`, + ); + expect(transformed.status).toBe(200); + expect(transformed.headers.get("content-type")).toMatch(/^image\//); + expect((await transformed.arrayBuffer()).byteLength).toBeGreaterThan(0); + + phase = "PgMeta, Studio, and Analytics health"; + const pgmeta = await fetch(`${stack.url}/pg/health`); + expect(pgmeta.status).toBe(200); + await pgmeta.arrayBuffer(); + const studio = await fetch( + `${requiredEndpoint(stack.serviceEndpoints, "studio")}/api/platform/profile`, + ); + expect(studio.status).toBe(200); + await studio.arrayBuffer(); + const analytics = await fetch(`${stack.url}/analytics/v1/health`); + expect(analytics.status).toBe(200); + await analytics.arrayBuffer(); + + phase = "Mailpit delivery"; + const recipient = `native-services-${Date.now()}@example.com`; + await smtpSend( + endpointPort(requiredEndpoint(stack.serviceEndpoints, "mailpit_smtp")), + "sender@example.com", + recipient, + "native services", + ); + const mailpitResponse = await fetch( + `${requiredEndpoint(stack.serviceEndpoints, "mailpit")}/api/v1/messages`, + ); + expect(mailpitResponse.status).toBe(200); + const mailpitBody = await readJson(mailpitResponse); + const mailpitMessages = + typeof mailpitBody === "object" && mailpitBody !== null + ? Reflect.get(mailpitBody, "messages") + : undefined; + expect(Array.isArray(mailpitMessages)).toBe(true); + expect(JSON.stringify(mailpitBody)).toContain(recipient); + + phase = "Pooler query"; + const pooled = new Bun.SQL(requiredEndpoint(stack.serviceEndpoints, "pooler")); + try { + const rows = await pooled.unsafe<{ answer: number }[]>("SELECT 40 + 2 AS answer"); + expect(rows[0]?.answer).toBe(42); + } finally { + await pooled.close(); + } + + const triggerId = String(Date.now()); + const triggerName = `native_vector_trigger_${triggerId}`; + const functionName = `native_vector_notify_${triggerId}`; + const channel = `native_vector_${triggerId}`; + const analyticsDbUrl = new URL(stack.dbUrl); + analyticsDbUrl.pathname = "/_supabase"; + const analyticsSql = new Bun.SQL(analyticsDbUrl.toString()); + let physicalTable: string | undefined; + let sourceToken: string | undefined; + let notificationSubscription: Awaited> | undefined; + let notificationResolve: ((payload: string) => void) | undefined; + try { + phase = "Analytics source setup"; + const sourceRows = await analyticsSql.unsafe<{ token: string }[]>(` + SELECT token::text AS token + FROM _analytics.sources + WHERE name = 'postgres.logs' + `); + sourceToken = sourceRows.length === 1 ? sourceRows[0]?.token : undefined; + if (typeof sourceToken !== "string" || !/^[0-9a-f-]+$/i.test(sourceToken)) { + throw new Error("Analytics postgres.logs source token is invalid"); + } + physicalTable = `log_events_${sourceToken.replaceAll("-", "_")}`; + phase = "Analytics Vector trigger setup"; + await analyticsSql.unsafe(` + CREATE OR REPLACE FUNCTION _analytics."${functionName}"() RETURNS trigger + LANGUAGE plpgsql AS $fn$ + BEGIN + IF NEW.event_message LIKE '%${EDGE_LOG_MARKER}%' THEN + PERFORM pg_notify('${channel}', NEW.event_message); + END IF; + RETURN NEW; + END; + $fn$; + DROP TRIGGER IF EXISTS "${triggerName}" ON _analytics."${physicalTable}"; + CREATE TRIGGER "${triggerName}" + AFTER INSERT ON _analytics."${physicalTable}" + FOR EACH ROW EXECUTE FUNCTION _analytics."${functionName}"(); + `); + const notification = new Promise((resolve) => { + notificationResolve = resolve; + }); + notificationSubscription = await analyticsSql.listen(channel, (payload) => { + notificationResolve?.(payload); + }); + phase = "Analytics Vector ingestion"; + const vectorFunctionResponse = await fetchFunctionWhenReady( + `${stack.url}/functions/v1/native-services`, + ); + expect(vectorFunctionResponse.status).toBe(200); + expect(await vectorFunctionResponse.text()).toBe("native services ready"); + const observedNotification = await withTimeout( + notification, + "Analytics Vector ingestion", + ); + expect(observedNotification).toContain(EDGE_LOG_MARKER); + const analyticsQueryUrl = new URL(`${stack.url}/analytics/v1/api/query`); + analyticsQueryUrl.search = new URLSearchParams({ + pg_sql: `SELECT event_message +FROM "postgres.logs" +WHERE "timestamp" > NOW() - INTERVAL '10' MINUTE + AND event_message LIKE '%${EDGE_LOG_MARKER}%' +ORDER BY "timestamp" DESC +LIMIT 1`, + }).toString(); + const analyticsQueryResponse = await fetch(analyticsQueryUrl, { + headers: { "x-api-key": "native-services-analytics-key" }, + }); + if (analyticsQueryResponse.status !== 200) { + const body = (await analyticsQueryResponse.text()).slice(0, 1_000); + throw new Error( + `Analytics Postgres query returned ${analyticsQueryResponse.status}: ${body}`, + ); + } + expect(JSON.stringify(await readJson(analyticsQueryResponse))).toContain(EDGE_LOG_MARKER); + } finally { + await notificationSubscription?.unlisten(); + if (physicalTable !== undefined) { + await analyticsSql + .unsafe( + `DROP TRIGGER IF EXISTS "${triggerName}" ON _analytics."${physicalTable}"; DROP FUNCTION IF EXISTS _analytics."${functionName}"();`, + ) + .catch(() => {}); + } + await analyticsSql.close(); + } + + phase = "Mailpit restart"; + const mailpitStream = stack.serviceLogs("mailpit")[Symbol.asyncIterator](); + const observedLog = mailpitStream.next(); + await stack.restartService("mailpit"); + const logEvent = await withTimeout(observedLog, "Mailpit restart log"); + await mailpitStream.return?.(); + expect(logEvent.done).toBe(false); + const logLine = logEvent.done ? "" : logEvent.value.line; + expect(logLine.length).toBeGreaterThan(0); + + const finalStates = await stack.getStatus(); + expect(finalStates).toHaveLength(NATIVE_SERVICES.length); + expect(finalStates.every((state) => state.status === "Healthy")).toBe(true); + const vectorConfig = readFileSync(join(runtimeRoot, "vector", "vector.yaml"), "utf8"); + expect(vectorConfig).toContain("postgres.logs"); + expect(vectorConfig).toContain(`${runtimeRoot}/logs/vector.jsonl`); + expect(vectorConfig).toContain("exclude:"); + } catch (error) { + const diagnostics = journeyDiagnostics?.format() ?? "status/log diagnostics unavailable"; + throw new Error( + `Native service journey failed during ${phase}: ${String(error)}\n${diagnostics}\n${realtimeClientDiagnostics?.format() ?? "realtime-client=[]"}`, + { cause: error }, + ); + } + }, + ); +});