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..8230eaa905 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=2.9.7.", + }); + }); }); 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..70fc71b0fa 100644 --- a/packages/process-compose/src/HealthProbe.ts +++ b/packages/process-compose/src/HealthProbe.ts @@ -14,7 +14,9 @@ const executeProbe = ( > => { return Match.valueTags(probe, { Http: (probe) => - HttpClient.get(`${probe.scheme}://${probe.host}:${probe.port}${probe.path}`).pipe( + 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), diff --git a/packages/process-compose/src/HealthProbe.unit.test.ts b/packages/process-compose/src/HealthProbe.unit.test.ts index fbc6f96d36..d84846997c 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,93 @@ 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 fetchWithHostHeader = Object.assign( + (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input : input.url; + const requestHeaders = + init?.headers === undefined + ? undefined + : Array.isArray(init.headers) + ? Object.fromEntries(init.headers) + : init.headers instanceof Headers + ? Object.fromEntries(init.headers.entries()) + : init.headers; + // oxlint-disable-next-line effecttsgo/new-promise -- The fetch replacement adapts node:http callbacks to the Fetch contract. + return new Promise((resolve, reject) => { + const request = Http.request( + url, + { + method: init?.method, + headers: requestHeaders, + }, + (response) => { + const chunks: Array = []; + response.on("data", (chunk: Uint8Array) => chunks.push(chunk)); + response.on("end", () => { + const responseHeaders = Object.fromEntries( + Object.entries(response.headers).flatMap(([key, value]) => + value === undefined + ? [] + : [[key, Array.isArray(value) ? value.join(", ") : value]], + ), + ); + resolve( + new Response(Buffer.concat(chunks), { + status: response.statusCode, + headers: responseHeaders, + }), + ); + }); + }, + ); + request.on("error", reject); + request.end(init?.body as string | Uint8Array | undefined); + }); + }, + { preconnect: globalThis.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, fetchWithHostHeader)), + ), + ); + 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/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..eee518c981 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. diff --git a/packages/process-compose/src/Orchestrator.unit.test.ts b/packages/process-compose/src/Orchestrator.unit.test.ts index f07fa0d54e..b7d8873f7e 100644 --- a/packages/process-compose/src/Orchestrator.unit.test.ts +++ b/packages/process-compose/src/Orchestrator.unit.test.ts @@ -341,6 +341,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* () { 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..a8434aba76 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, 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/LocalStack.ts b/packages/stack/src/LocalStack.ts index a4c4509b3e..91cd42d73b 100644 --- a/packages/stack/src/LocalStack.ts +++ b/packages/stack/src/LocalStack.ts @@ -64,6 +64,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"; @@ -184,7 +185,11 @@ const stackInfoFor = (config: ResolvedStackConfig): StackInfo => { ...(config.pooler === false ? {} : { - pooler: `postgresql://postgres:postgres@127.0.0.1:${config.pooler.port}/postgres`, + pooler: `postgresql://postgres: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}`, }), }, @@ -237,6 +242,10 @@ export const localStackLayer = ( config.functions === false ? undefined : config.functions, ); const edgeRuntimeConfigRef = yield* Ref.make(config.edgeRuntime); + // A whole-stack stop changes the orchestrator desired state for every running service to + // `stopped`. Keep that lifecycle intent separate from an explicit service stop so a later + // lazy activation can restore only services that were running before the whole-stack stop. + const wholeStackStoppedServicesRef = yield* Ref.make>(new Set()); const disposedSignal = yield* Deferred.make(); const lifecycleLock = Semaphore.makeUnsafe(1); const projectionLock = Semaphore.makeUnsafe(1); @@ -320,6 +329,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, @@ -506,6 +516,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)), @@ -626,13 +643,30 @@ export const localStackLayer = ( const withLifecycleLock = lifecycleLock.withPermit; const syncRuntimeProjectedStates = (runtime: RuntimeState) => syncProjectedStates(runtime.orchestrator, runtime.serviceProjection); + const clearWholeStackStopAllowance = (services: ReadonlyArray) => + Ref.update(wholeStackStoppedServicesRef, (current) => { + const next = new Set(current); + for (const service of services) next.delete(service); + return next; + }); + const wholeStackStopAllowance = Ref.get(wholeStackStoppedServicesRef); + const rememberWholeStackStoppedServices = (runtime: RuntimeState) => + Effect.gen(function* () { + const running = (yield* runtime.orchestrator.getAllStates).flatMap((state) => { + if (state.desired !== "running") return []; + const service = SERVICE_NAMES.find((candidate) => candidate === state.name); + return service !== undefined && enabledServices.includes(service) ? [service] : []; + }); + yield* Ref.set(wholeStackStoppedServicesRef, new Set(running)); + }); const serviceStartOptions = { // Reservation may yield while disposal flips the lifecycle state. beforeStart: (name: string) => portLease - .reserve(portFieldsForService(name)) + .reserve(portFieldsForService(name, config.runtime.mode)) .pipe(Effect.andThen(requireMutable(`start service ${name}`))), - beforeSpawn: (name: string) => portLease.release(portFieldsForService(name)), + beforeSpawn: (name: string) => + portLease.release(portFieldsForService(name, config.runtime.mode)), }; const knownServiceError = (service: string, cause: ServiceNotFoundError) => new StackBuildError({ @@ -736,6 +770,11 @@ export const localStackLayer = ( return yield* new StackNotRunningError({ phase }); } }); + const clearWholeStackStopAllowanceAfterSuccess = (services: ReadonlyArray) => + Effect.gen(function* () { + yield* requireRunningPhase; + yield* clearWholeStackStopAllowance(services); + }).pipe(lifecycleLock.withPermit); const requireMutable = (operation: string) => Effect.suspend(() => disposed || disposing @@ -779,6 +818,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); @@ -849,6 +889,9 @@ export const localStackLayer = ( // Close the race with a concurrent stack stop before taking // the lock-free healthy-request fast path. yield* requireRunningPhase; + yield* clearWholeStackStopAllowanceAfterSuccess( + lifecycleTargetsForService(enabledServices, service), + ); return; } if (existing !== undefined) { @@ -859,6 +902,9 @@ export const localStackLayer = ( activationReadinessPolicy(service, config.readiness, config.readinessSource), ), ); + yield* clearWholeStackStopAllowanceAfterSuccess( + lifecycleTargetsForService(enabledServices, service), + ); return; } yield* prepareServices([service]); @@ -866,7 +912,8 @@ export const localStackLayer = ( yield* requireRunningPhase; const concurrentlyStarted = yield* inspectStartedTargets(service); if (concurrentlyStarted !== undefined) return concurrentlyStarted; - return yield* beginStartTargets(service, new Set()); + const allowedWholeStackStops = yield* wholeStackStopAllowance; + return yield* beginStartTargets(service, allowedWholeStackStops); }).pipe(withLifecycleLock); yield* waitForTargets(started).pipe((effect) => withReadinessPolicy( @@ -875,6 +922,9 @@ export const localStackLayer = ( activationReadinessPolicy(service, config.readiness, config.readinessSource), ), ); + yield* clearWholeStackStopAllowanceAfterSuccess( + lifecycleTargetsForService(enabledServices, service), + ); }).pipe(cleanupOnReadinessFailure); const stack = { @@ -933,6 +983,7 @@ export const localStackLayer = ( (effect) => withReadinessPolicy(effect, "stack"), ); yield* syncRuntimeProjectedStates(runtime); + yield* clearWholeStackStopAllowance(["postgres", ...eager]); } else { yield* prepareServices(enabledServices); yield* requireMutable("start"); @@ -942,6 +993,7 @@ export const localStackLayer = ( withReadinessPolicy(effect, "stack"), ); yield* syncRuntimeProjectedStates(runtime); + yield* clearWholeStackStopAllowance(enabledServices); } yield* requireMutable("start"); yield* Ref.set(phaseRef, "running"); @@ -956,10 +1008,16 @@ export const localStackLayer = ( if (disposed) { return; } + const phase = yield* Ref.get(phaseRef); + if (phase === "stopped") { + return; + } if (runtimeState === undefined) { + yield* Ref.set(wholeStackStoppedServicesRef, new Set()); yield* Ref.set(phaseRef, "stopped"); return; } + yield* rememberWholeStackStoppedServices(runtimeState); yield* Ref.set(phaseRef, "stopping"); yield* runtimeState.orchestrator.stop; yield* Ref.set(phaseRef, "stopped"); @@ -974,12 +1032,19 @@ export const localStackLayer = ( const started = yield* Effect.gen(function* () { yield* requireMutable(`start service ${name}`); yield* requireRunningPhase; + const allowedWholeStackStops = yield* wholeStackStopAllowance; return yield* beginStartTargets( service, - new Set(lifecycleTargetsForService(enabledServices, service)), + new Set([ + ...allowedWholeStackStops, + ...lifecycleTargetsForService(enabledServices, service), + ]), ); }).pipe(withLifecycleLock); yield* waitForTargets(started).pipe((effect) => withReadinessPolicy(effect, name)); + yield* clearWholeStackStopAllowanceAfterSuccess( + lifecycleTargetsForService(enabledServices, service), + ); }).pipe(cleanupOnReadinessFailure), stopService: (name) => Effect.gen(function* () { @@ -993,6 +1058,9 @@ export const localStackLayer = ( ).toReversed()) { yield* runtime.orchestrator.stopService(target); } + yield* clearWholeStackStopAllowance( + lifecycleTargetsForService(enabledServices, service), + ); // Settle the public projection before returning so callers observe // the stop immediately, matching the start/restart/waitReady paths. yield* syncRuntimeProjectedStates(runtime); @@ -1011,6 +1079,7 @@ export const localStackLayer = ( return { runtime, targets: [service] }; }).pipe(withLifecycleLock); yield* waitForTargets(started).pipe((effect) => withReadinessPolicy(effect, name)); + yield* clearWholeStackStopAllowanceAfterSuccess([service]); }).pipe(cleanupOnReadinessFailure), reloadFunctions: (opts) => Effect.gen(function* () { diff --git a/packages/stack/src/NativeLogWriter.integration.test.ts b/packages/stack/src/NativeLogWriter.integration.test.ts new file mode 100644 index 0000000000..0776f7823c --- /dev/null +++ b/packages/stack/src/NativeLogWriter.integration.test.ts @@ -0,0 +1,772 @@ +// 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, + subscribeAll: Stream.make( + { + timestamp: 0, + service: "auth", + stream: "stdout", + line: "failed", + }, + { + timestamp: 0, + service: "postgres", + stream: "stdout", + 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..375cc2a506 --- /dev/null +++ b/packages/stack/src/NativeLogWriter.ts @@ -0,0 +1,471 @@ +// 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 = `\u0000native-log-writer:${runtimeRoot}`; + const worker = Effect.gen(function* () { + const pull = yield* Stream.toPull(logBuffer.subscribeAll); + 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 entry of chunk) { + if (entry.service === shutdownMarker && entry.line === shutdownMarker) { + running = false; + break; + } + 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 + .append(shutdownMarker, "stderr", 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..8fac1e7630 --- /dev/null +++ b/packages/stack/src/NativeServiceGraphLifecycle.integration.test.ts @@ -0,0 +1,467 @@ +// 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 { 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"; + +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 "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", + "pooler-migrate", + "pooler-bootstrap", +]); + +const controllableSpawner = () => { + 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); + 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, +}); + +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 }), +) => { + const layer = localStackLayer(config, { + ports: config.ports, + reserve: () => Effect.void, + release: () => Effect.void, + releaseAll: Effect.void, + }).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("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/PortCatalog.ts b/packages/stack/src/PortCatalog.ts index fe879a414e..bcd37f3844 100644 --- a/packages/stack/src/PortCatalog.ts +++ b/packages/stack/src/PortCatalog.ts @@ -18,7 +18,10 @@ 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; } @@ -39,13 +42,17 @@ 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; readonly persistence: "runtime" | "sticky"; } @@ -119,14 +126,33 @@ 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", + }, }; export const PORT_CATALOG = PORT_CATALOG_ENTRIES; @@ -147,7 +173,9 @@ export const PORT_FIELDS = [ "pgmetaPort", "studioPort", "analyticsPort", - "poolerPort", + "vectorAdminPort", + "poolerSessionPort", + "poolerTransactionPort", "poolerApiPort", ] as const satisfies ReadonlyArray; export const stickyPortFields: ReadonlyArray = PORT_FIELDS.filter( @@ -177,7 +205,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,7 +227,9 @@ 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, }); @@ -218,7 +250,9 @@ 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), }); diff --git a/packages/stack/src/ServiceCatalog.ts b/packages/stack/src/ServiceCatalog.ts index d04d709971..3421ec4666 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.165", - runtimeSupport: "native-preferred", + defaultVersions: { native: "17.6.1.165", docker: "17.6.1.165" }, 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,10 +201,10 @@ export const SERVICE_CATALOG = { realtime: { name: "realtime", configKey: "realtime", - defaultVersion: "v2.129.9", - runtimeSupport: "docker-only", + defaultVersions: { native: "v2.129.1", docker: "v2.129.9" }, artifact: { docker: { repository: "realtime" }, + native: nativeSource("realtime", ["bin/migrate", "bin/realtime", "bin/server"]), }, activation: { activates: [], owns: [] }, preparation: preparation(["eager"], "eager", ["postgres"]), @@ -217,10 +213,10 @@ export const SERVICE_CATALOG = { storage: { name: "storage", configKey: "storage", - defaultVersion: "v1.71.0", - runtimeSupport: "docker-only", + defaultVersions: { native: "v1.70.1", docker: "v1.71.0" }, 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.98.0", - runtimeSupport: "docker-only", + defaultVersions: { native: "v0.98.0", docker: "v0.98.0" }, artifact: { - docker: { repository: "pgmeta", tagPrefix: "v" }, + docker: { repository: "pgmeta" }, + 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,53 @@ 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.7", - runtimeSupport: "docker-only", + defaultVersions: { native: "v2.9.10", docker: "2.9.7" }, 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"], }, -} 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 DOCKER_DEFAULT_VERSIONS: Readonly> = Record.map( + SERVICE_CATALOG, + (metadata) => metadata.defaultVersions.docker, ); -export const serviceMetadata = (service: ServiceName): ServiceCatalogEntry => - SERVICE_CATALOG[service]; +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 e638d17336..24c1220921 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -44,8 +44,10 @@ const defaultPorts: AllocatedPorts = { pgmetaPort: 54336, studioPort: 54337, analyticsPort: 54338, - poolerPort: 54339, - poolerApiPort: 54340, + vectorAdminPort: 54341, + poolerSessionPort: 54339, + poolerTransactionPort: 54340, + poolerApiPort: 54341, }; const defaultConfig: ResolvedStackConfig = { @@ -614,26 +616,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(); @@ -886,7 +868,7 @@ describe("Stack", () => { }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); - it.live("restarts activated companions after stopping the stack", () => { + it.live("restarts activated analytics companions after stopping and restarting the stack", () => { const graph = Effect.runSync( buildGraph([ { @@ -962,10 +944,10 @@ describe("Stack", () => { ...defaultConfig.servicePolicies, auth: "off", postgrest: "lazy", - pgmeta: "eager", - studio: "eager", - analytics: "eager", - vector: "eager", + pgmeta: "off", + studio: "off", + analytics: "lazy", + vector: "lazy", }, auth: false, } satisfies ResolvedStackConfig; @@ -980,11 +962,16 @@ describe("Stack", () => { return Effect.gen(function* () { const stack = yield* Stack; + const activator = yield* StackServiceActivator; yield* stack.start; + yield* activator.activate("analytics"); + expect((yield* stack.getState("analytics")).status).toBe("Healthy"); + expect((yield* stack.getState("vector")).status).toBe("Healthy"); yield* stack.stop; yield* stack.start; + yield* stack.restartService("analytics"); + yield* activator.activate("analytics"); - expect((yield* stack.getState("studio")).status).toBe("Healthy"); expect((yield* stack.getState("analytics")).status).toBe("Healthy"); expect((yield* stack.getState("vector")).status).toBe("Healthy"); }).pipe(Effect.provide(layer), Effect.timeout("10 seconds")); @@ -1453,6 +1440,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..12778d61ad --- /dev/null +++ b/packages/stack/src/StackBuilder.integration.test.ts @@ -0,0 +1,311 @@ +// 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, +}; + +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 result = 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 yield* builder + .build(config, prepared) + .pipe(Effect.provideService(Scope.Scope, scope)); + }).pipe(Effect.provide(layer)), + ), + ); + + const names = result.graph.startOrder.map((service) => service.name); + expect(names).toEqual( + expect.arrayContaining([ + ...SERVICE_NAMES, + "postgres-init", + "realtime-migrate", + "realtime-seed", + "analytics-migrate", + "pooler-migrate", + "pooler-bootstrap", + ]), + ); + expect(names).toHaveLength(19); + 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("/opt/slim/"); + } + 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 === "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"], + ); + 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), + 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("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..45e59453d8 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,19 +58,20 @@ 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", -); - // Serial health-check paths used by dependency waits; keep each path aligned // with the corresponding service's transitive dependencies. 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 beamReleaseCookie = (identityKey: string): string => + `supabase_${identityKey.replaceAll("-", "_")}_cookie`; + const postgresDependencyTimeoutSeconds = dependencyTimeoutSecondsForServices(postgresStartupPath); const postgresDependencies: ReadonlyArray = [ @@ -72,23 +80,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 +122,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 +192,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 +220,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,22 +245,6 @@ 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, { @@ -280,13 +289,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 = @@ -326,9 +341,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 +361,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 +402,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 +413,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 +429,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 +442,85 @@ 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, + runtimeRoot: config.runtimeRoot, + 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, + 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, + })), 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: beamReleaseCookie(identity.key), port: config.realtime.port, - identity, - dbHost: serviceHost, dbPort: config.dbPort, jwtSecret: config.jwtSecret, jwtJwks, @@ -479,129 +528,271 @@ 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"); + 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, + dependencies: [{ service: "storage", condition: "healthy" }], + }) + : makeImgproxyServiceDocker({ + runtime: yield* requireContainerRuntime, + image: imgproxyResolution.image, + port: config.imgproxy.port, + identity, + dataDir: storageConfig === false ? "" : 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), 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"); + defs.push( + { + ...bundle.migrate, + 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") { + 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, + nodeName: beamNodeName("pooler", identity.key), + releaseCookie: beamReleaseCookie(identity.key), dbPort: config.dbPort, poolMode: config.pooler.mode, defaultPoolSize: config.pooler.defaultPoolSize, @@ -611,43 +802,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 +937,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..cef251ff0e 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -47,8 +47,10 @@ const basePorts: AllocatedPorts = { pgmetaPort: 3016, studioPort: 3017, analyticsPort: 3018, - poolerPort: 3019, - poolerApiPort: 3020, + vectorAdminPort: 3021, + poolerSessionPort: 3019, + poolerTransactionPort: 3020, + poolerApiPort: 3021, }; const baseConfig: ResolvedStackConfig = { diff --git a/packages/stack/src/StackConfig.ts b/packages/stack/src/StackConfig.ts index 0255dbc560..1b7e4bb301 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,10 +292,13 @@ 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; readonly mode: "transaction" | "session"; readonly version: string; diff --git a/packages/stack/src/StackConfigResolver.policy.unit.test.ts b/packages/stack/src/StackConfigResolver.policy.unit.test.ts index 696e34c320..c7006e0146 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,10 @@ 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, }; const resolveConfig = ( @@ -86,6 +90,58 @@ 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.7" }); + }); + + 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, + }); + 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..75ef539cc5 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,39 @@ 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, + versions: VersionResolutionOptions, ): Effect.Effect { if (raw === false) return Effect.succeed(false); const cfg = input ?? {}; return Effect.all({ - port: requiredPort(ports, "poolerPort"), + sessionPort: requiredPort(ports, "poolerSessionPort"), + transactionPort: requiredPort(ports, "poolerTransactionPort"), apiPort: requiredPort(ports, "poolerApiPort"), }).pipe( - Effect.map(({ port, apiPort }) => ({ - port, + Effect.map(({ sessionPort, transactionPort, apiPort }) => ({ + sessionPort, + transactionPort, apiPort, 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 +473,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,8 +650,12 @@ 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 "postgrestPort": @@ -666,6 +707,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 +798,88 @@ 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, 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..25b95393d8 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,10 +245,7 @@ const preflight = ( existing.launch, context.input.launch, ); - const effectiveConfigInput = - persistedRuntime.mode === "native" && context.configInput.mode === undefined - ? applyNativeDefaults(withExclusions) - : withExclusions; + const effectiveConfigInput = withExclusions; const portRequests = yield* portRequestsForConfig(effectiveConfigInput, { runtime: persistedRuntime, }).pipe(Effect.mapError((cause) => preflightError(context, causeMessage(cause)))); diff --git a/packages/stack/src/bun.ts b/packages/stack/src/bun.ts index 821af22053..7a31121494 100644 --- a/packages/stack/src/bun.ts +++ b/packages/stack/src/bun.ts @@ -43,14 +43,19 @@ export async function prefetch(options?: PrefetchOptions): Promise { throw toStackError(error); }); - const resolverLayer = BinaryResolver.make(defaultCacheRoot()).pipe( + const resolverLayer = BinaryResolver.make(options?.cacheRoot ?? defaultCacheRoot()).pipe( Layer.provide(FetchHttpClient.layer), ); const preparationLayer = StackPreparation.layer.pipe(Layer.provide(resolverLayer)); + const effectOptions = { + versions: options?.versions, + services: options?.services, + enabledServices: options?.enabledServices, + }; const resolvedOptions: PrefetchEffectOptions = runtime.mode === "native" - ? { ...options, mode: "native" } - : { ...options, mode: "docker", containerRuntime: runtime.containerRuntime }; + ? { ...effectOptions, mode: "native" } + : { ...effectOptions, mode: "docker", containerRuntime: runtime.containerRuntime }; return Effect.runPromise( prefetchEffect(resolvedOptions).pipe( Effect.provide(preparationLayer), diff --git a/packages/stack/src/createStack.integration.test.ts b/packages/stack/src/createStack.integration.test.ts index 26113c6a9e..90e0520e90 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(); diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index 2d4755d462..93b198a32f 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; @@ -232,6 +234,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..ef4001ee6b 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,10 @@ 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_018, } satisfies Record; return { ...ports, apiPort: config?.port ?? ports.apiPort }; }; @@ -167,11 +170,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 +221,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 +249,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 +289,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..ca573f77f2 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, startNativeLogWriter } 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.unit.test.ts b/packages/stack/src/functions.unit.test.ts index d28c0259da..7d73a2eacd 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -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 = ( @@ -153,6 +154,7 @@ describe("stack Functions runtime config", () => { const error = await resolveConfig({ mode: "native", projectDir: root, + edgeRuntime: false, functions: makeBundle(root), }).then( () => undefined, diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index a482ef0b0a..9bb7e4783e 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -29,6 +29,8 @@ export type { ServiceName, VersionManifest } from "./versions.ts"; export type { ServiceResolution, StackPreparationError } from "./StackPreparation.ts"; 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/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/node.ts b/packages/stack/src/node.ts index 3a5aa6ba6e..cc0dd30d47 100644 --- a/packages/stack/src/node.ts +++ b/packages/stack/src/node.ts @@ -50,14 +50,19 @@ export async function prefetch(options?: PrefetchOptions): Promise { throw toStackError(error); }); - const resolverLayer = BinaryResolver.make(defaultCacheRoot()).pipe( + const resolverLayer = BinaryResolver.make(options?.cacheRoot ?? defaultCacheRoot()).pipe( Layer.provide(FetchHttpClient.layer), ); const preparationLayer = StackPreparation.layer.pipe(Layer.provide(resolverLayer)); + const effectOptions = { + versions: options?.versions, + services: options?.services, + enabledServices: options?.enabledServices, + }; const resolvedOptions: PrefetchEffectOptions = runtime.mode === "native" - ? { ...options, mode: "native" } - : { ...options, mode: "docker", containerRuntime: runtime.containerRuntime }; + ? { ...effectOptions, mode: "native" } + : { ...effectOptions, mode: "docker", containerRuntime: runtime.containerRuntime }; return Effect.runPromise( prefetchEffect(resolvedOptions).pipe( Effect.provide(preparationLayer), diff --git a/packages/stack/src/prefetch.ts b/packages/stack/src/prefetch.ts index c4a26af9fc..d843fac44b 100644 --- a/packages/stack/src/prefetch.ts +++ b/packages/stack/src/prefetch.ts @@ -10,13 +10,15 @@ import { StackPreparation } from "./StackPreparation.ts"; import type { ServiceName } from "./ServiceName.ts"; export interface PrefetchOptions { + /** Root directory used for native binary cache entries. */ + readonly cacheRoot?: string; readonly versions?: StackPreparationInput["versions"]; readonly services?: StackPreparationInput["services"]; readonly enabledServices?: StackPreparationInput["enabledServices"]; readonly mode?: "native" | "docker"; } -export type PrefetchEffectOptions = Omit & +export type PrefetchEffectOptions = Omit & ( | { readonly mode?: "native"; readonly containerRuntime?: never } | { readonly mode: "docker"; readonly containerRuntime: ContainerRuntime } diff --git a/packages/stack/src/prefetch.unit.test.ts b/packages/stack/src/prefetch.unit.test.ts index 6895ff2e3c..842f2d699e 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.7", + }); + 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), diff --git a/packages/stack/src/services/analytics.ts b/packages/stack/src/services/analytics.ts index 6f4bcfcf03..99c5705764 100644 --- a/packages/stack/src/services/analytics.ts +++ b/packages/stack/src/services/analytics.ts @@ -1,25 +1,45 @@ +// 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, + 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; +} + +export interface NativeAnalyticsServiceBundle { + /** Runs the bundled Logflare migration once before the server starts. */ + readonly migrate: 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; export const analyticsDockerRuntimeNetwork = ( @@ -42,35 +62,59 @@ 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; + }, +): 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_PRIVATE_ACCESS_TOKEN: opts.apiKey, + LOGFLARE_LOG_LEVEL: "warn", + LOGFLARE_NODE_HOST: opts.nodeHost, + LOGFLARE_FEATURE_FLAG_OVERRIDE: "'multibackend=true'", + RELEASE_COOKIE: "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 + ? {} + : { + 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 +129,32 @@ 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 server = nativeRunService({ + name: "analytics", + command: `${opts.binPath}/bin/logflare`, + args: ["start", "--sname", opts.nodeName], + env, + dependencies: [{ service: migrate.name, condition: "completed" }], + healthCheck: analyticsHealthCheck(opts.hostPort), + }); + return { migrate, 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..d0208ef34b --- /dev/null +++ b/packages/stack/src/services/analytics.unit.test.ts @@ -0,0 +1,52 @@ +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", + 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.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-migrate", 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_CRASH_DUMP: "/tmp/stacks/project-a/runtime/analytics/erl_crash.dump", + 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.ts b/packages/stack/src/services/edge-runtime.ts index 4986896803..bea75457e3 100644 --- a/packages/stack/src/services/edge-runtime.ts +++ b/packages/stack/src/services/edge-runtime.ts @@ -10,12 +10,13 @@ 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; @@ -27,6 +28,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; @@ -125,3 +132,15 @@ export const makeEdgeRuntimeServiceDocker = (opts: DockerEdgeRuntimeOptions): Se healthCheck: edgeRuntimeHealthCheck(opts.port), }); }; + +export const makeEdgeRuntimeServiceNative = (opts: NativeEdgeRuntimeOptions): ServiceDef => + nativeRunService({ + name: "edge-runtime", + command: `${opts.binPath}/bin/.edge-runtime-wrapped`, + args: [...edgeRuntimeArgs(opts, opts.bootstrapDir)], + cwd: opts.projectDir, + 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..455d75b81f 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 Omit { + 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,30 @@ const imgproxyHealthCheck = (port: number): ServiceDef["healthCheck"] => ...stackHealthBudgets.imgproxy, }); +const imgproxyEnv = (port: number, host: "127.0.0.1" | "") => ({ + 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_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"), + dependencies: opts.dependencies, + healthCheck: imgproxyHealthCheck(opts.port), + }); + export const makeImgproxyServiceDocker = (opts: DockerImgproxyOptions): ServiceDef => dockerRunService({ runtime: opts.runtime, @@ -33,17 +66,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, ""), 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..3c6fee6eb7 100644 --- a/packages/stack/src/services/pgmeta.ts +++ b/packages/stack/src/services/pgmeta.ts @@ -3,21 +3,40 @@ 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 }, +): Record => ({ + 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 +55,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), 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..c75d6c2e14 100644 --- a/packages/stack/src/services/pooler.ts +++ b/packages/stack/src/services/pooler.ts @@ -1,8 +1,11 @@ +// 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, + nativeRunService, type ContainerRuntimeOptions, type ServiceDependency, } from "./service-utils.ts"; @@ -10,11 +13,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 +23,38 @@ 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; + /** 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", @@ -40,7 +67,10 @@ const poolerHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ }); 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 @@ -80,13 +110,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 +131,69 @@ 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 '${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 poolerNativeEnv = (opts: NativePoolerOptions): Record => ({ + PORT: String(opts.adminPort), + PROXY_PORT_SESSION: String(opts.sessionPort), + PROXY_PORT_TRANSACTION: String(opts.transactionPort), + 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", + ERL_AFLAGS: "-proto_dist inet_tcp", + 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 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, + 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..80eddd7291 --- /dev/null +++ b/packages/stack/src/services/pooler.unit.test.ts @@ -0,0 +1,79 @@ +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") => + makePoolerServicesNative({ + binPath: "/cache/pooler/v2.9.10/darwin-arm64", + runtimeRoot: "/tmp/stacks/project-a/runtime", + adminPort: 54329, + sessionPort: 54330, + transactionPort: 54331, + 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: "pooler-dev", + 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.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", + 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", + }); + }); +}); diff --git a/packages/stack/src/services/realtime.ts b/packages/stack/src/services/realtime.ts index 0783ed830f..66dac346bb 100644 --- a/packages/stack/src/services/realtime.ts +++ b/packages/stack/src/services/realtime.ts @@ -3,15 +3,15 @@ import { dockerNetworkArgs } from "../Platform.ts"; 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 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 +20,68 @@ 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 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: "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 + ? {} + : { + 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 +98,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 +111,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); + 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..57721ebb7a 100644 --- a/packages/stack/src/services/service-utils.ts +++ b/packages/stack/src/services/service-utils.ts @@ -32,6 +32,39 @@ 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"]; +} + +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 +83,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 b7ace03bc6..82d8e7b40e 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -7,23 +7,28 @@ import { describe, expect, it } from "vitest"; import { Predicate } from "effect"; import { analyticsDockerRuntimeNetwork, makeAnalyticsServiceDocker } from "./analytics.ts"; import { makeAuthServiceNative, makeAuthServiceDocker } from "./auth.ts"; -import { makeEdgeRuntimeServiceDocker } from "./edge-runtime.ts"; +import { makeEdgeRuntimeServiceDocker, makeEdgeRuntimeServiceNative } 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 { makePoolerServiceDocker, poolerContainerPorts } from "./pooler.ts"; +import { makeRealtimeServiceDocker, makeRealtimeServicesNative } from "./realtime.ts"; +import { + makePoolerServiceDocker, + makePoolerServicesNative, + 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"; @@ -145,6 +150,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"); }); @@ -365,6 +371,342 @@ describe("makeEdgeRuntimeServiceDocker", () => { }); }); +describe("native auxiliary service definitions", () => { + it("starts Edge Runtime from the prepared wrapper and generated host paths", () => { + 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, + runtimeRoot, + 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=${bootstrapDir}`, + "--port=54340", + "--policy=per_worker", + ]); + expect(def.cwd).toBe(projectDir); + expect(def.env).toMatchObject({ + SUPABASE_INTERNAL_DEBUG: "true", + FUNCTIONS_RUNTIME_CONFIG_PATH: `${runtimeRoot}/edge-runtime/functions-runtime-config.json`, + }); + 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), + 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("binds both native Pooler protocol ports with stack-unique distribution identity", () => { + const artifactRoot = "/cache/pooler/v2.9.10/linux-amd64"; + const dependencies = [{ service: "postgres", condition: "healthy" }] as const; + const bundle = makePoolerServicesNative({ + binPath: artifactRoot, + runtimeRoot: "/tmp/stacks/project-a/runtime", + adminPort: 54329, + sessionPort: 54330, + transactionPort: 54331, + nodeName: "supavisor_id_stack_a", + releaseCookie: "supabase_stack_a_cookie", + dbPort: DB_PORT, + poolMode: "transaction", + defaultPoolSize: 20, + maxClientConn: 100, + jwtSecret: JWT_SECRET, + tenantId: "native-pooler", + encryptionKey: "native-encryption-key", + secretKeyBase: "native-secret-key-base", + dependencies, + }); + + expect(bundle.server.env).toMatchObject({ + PORT: "54329", + PROXY_PORT_SESSION: "54330", + PROXY_PORT_TRANSACTION: "54331", + 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", + }); + expect(bundle.migrate.env).toMatchObject({ + NODE_NAME: "supavisor_id_stack_a", + NODE_IP: "127.0.0.1", + RELEASE_COOKIE: "supabase_stack_a_cookie", + }); + }); + + 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_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, + 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_USE_ETAG: "/", + }); + expect(imgproxy.env?.IMGPROXY_LOCAL_FILESYSTEM_ROOT).toBe("/"); + expect(imgproxy).not.toHaveProperty("supervision.orphanCleanup"); + }); +}); + describe("makePostgresInitService", () => { it("creates a one-shot postgres-init ServiceDef", () => { const def = makePostgresInitService({ @@ -603,6 +945,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"); }); @@ -623,18 +966,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, @@ -659,6 +1004,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 e739aa962e..9d9aa9c59c 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..ba706cc96a --- /dev/null +++ b/packages/stack/src/services/vector.native.integration.test.ts @@ -0,0 +1,53 @@ +// 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("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..5aef6c2c2e 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,80 @@ 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 + decoding: + codec: json + +sinks: + logflare: + type: http + inputs: + - 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 +236,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..d344113b81 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,10 +673,7 @@ const makeRunManagedExecution = ( ownedPersistedRuntime === undefined ? yield* selectStackRuntime(requestedMode) : yield* validateStackRuntime(ownedPersistedRuntime); - const runtimeConfigInput = - runtime.mode === "native" && requestedMode === undefined - ? applyNativeDefaults(configInput) - : configInput; + const runtimeConfigInput = configInput; const activeFields = portFieldsForConfigInput({ ...runtimeConfigInput, mode: runtime.mode, 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 086a91fa0f..f9cbfda16b 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,10 +117,24 @@ describe("dockerImageForService", () => { }); it("uses the upstream mirror repositories for vector and pooler", () => { - expect(dockerImageForService("vector", DEFAULT_VERSIONS.vector)).toBe( + 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.7", + ); + }); + + 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.7"); + 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", DEFAULT_VERSIONS.pooler)).toBe( + expect(dockerImageForService("pooler", DOCKER_DEFAULT_VERSIONS.pooler)).toBe( "ghcr.io/supabase/supavisor:2.9.7", ); }); @@ -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", DEFAULT_VERSIONS.pgmeta)).toBe( + "ghcr.io/supabase/cli/pgmeta:v0.98.0", + ); + expect(dockerImageForArtifact("pooler", DOCKER_DEFAULT_VERSIONS.pooler)).toBe( + "ghcr.io/supabase/supavisor:2.9.7", + ); + }); + + 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 08d2d49971..ad7a26b7c9 100644 --- a/packages/stack/tests/createStack-docker.e2e.test.ts +++ b/packages/stack/tests/createStack-docker.e2e.test.ts @@ -1,22 +1,79 @@ // oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-fetch, effecttsgo/node-builtin-import -- Docker e2e tests drive the native CLI, Docker, and HTTP boundaries from Vitest's Promise callbacks. import { createClient, type SupabaseClient } from "@supabase/supabase-js"; -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { activationTimeoutSecondsForService } from "../src/ServiceActivation.ts"; import { createStack, type StackHandle } from "../src/node.ts"; import { dependencyTimeoutSecondsForServices } from "../src/services/health-budgets.ts"; -import { DEFAULT_VERSIONS } from "../src/versions.ts"; +import { SERVICE_NAMES, type ServiceName } from "../src/versions.ts"; import { setupTestTable } from "./helpers/e2e.ts"; -const STACK_DOCKER_E2E_TEST_TIMEOUT_MS = 180_000; -const STACK_DOCKER_E2E_SETUP_OVERHEAD_MS = 90_000; -const STACK_DOCKER_E2E_SETUP_TIMEOUT_MS = +const STACK_DOCKER_E2E_SETUP_OVERHEAD_MS = 180_000; +const STACK_DOCKER_E2E_FULL_START_TIMEOUT_MS = dependencyTimeoutSecondsForServices(["postgres"]) * 1000 + STACK_DOCKER_E2E_SETUP_OVERHEAD_MS; -const ANALYTICS_COLD_START_TEST_TIMEOUT_MS = activationTimeoutSecondsForService("analytics") * 1000; +const STACK_DOCKER_E2E_JOURNEY_OVERHEAD_MS = 240_000; +const STACK_DOCKER_E2E_TEST_TIMEOUT_MS = + 2 * STACK_DOCKER_E2E_FULL_START_TIMEOUT_MS + STACK_DOCKER_E2E_JOURNEY_OVERHEAD_MS; + +const EXPECTED_SLIM_DOCKER_IMAGES: Readonly> = { + postgres: "ghcr.io/supabase/cli/postgres:17.6.1.165", + postgrest: "ghcr.io/supabase/cli/postgrest:v16.2", + auth: "ghcr.io/supabase/cli/auth:v2.196.0", + "edge-runtime": "ghcr.io/supabase/cli/edge-runtime:v1.74.3", + realtime: "ghcr.io/supabase/cli/realtime:v2.129.9", + storage: "ghcr.io/supabase/cli/storage:v1.71.0", + imgproxy: "ghcr.io/supabase/cli/imgproxy:v3.8.0", + mailpit: "ghcr.io/supabase/cli/mailpit:v1.30.2", + pgmeta: "ghcr.io/supabase/cli/pgmeta:v0.98.0", + studio: "ghcr.io/supabase/cli/studio:2026.08.24-sha-8ec45b2", + analytics: "ghcr.io/supabase/cli/analytics:v1.50.6", + vector: "ghcr.io/supabase/vector:0.53.0-alpine", + pooler: "ghcr.io/supabase/supavisor:2.9.7", +}; + +const EAGER_SERVICES: ReadonlyArray = [ + "postgres", + "realtime", + "mailpit", + "pgmeta", + "studio", + "analytics", + "vector", + "pooler", +]; + +const LAZY_SERVICES: ReadonlyArray = [ + "postgrest", + "auth", + "edge-runtime", + "storage", + "imgproxy", +]; + +const ownedDockerContainerName = (service: ServiceName, identity: string): string => + `supabase-${service}-${identity}`; + +const ownedDockerContainers = ( + identity: string, +): ReadonlyArray<{ readonly name: string; readonly image: string }> => + SERVICE_NAMES.flatMap((service) => { + const name = ownedDockerContainerName(service, identity); + const output = execFileSync( + "docker", + ["ps", "-a", "--filter", `name=^${name}$`, "--format", "{{.Names}}\t{{.Image}}"], + { stdio: ["ignore", "pipe", "pipe"] }, + ) + .toString() + .trim(); + if (output === "") return []; + return output.split(/\r?\n/).map((line) => { + const [containerName, image] = line.split("\t"); + return { name: containerName ?? name, image: image ?? "" }; + }); + }); function hasDockerDaemon(): boolean { try { @@ -41,8 +98,34 @@ dockerDescribe("createStack e2e (docker mode)", () => { stack = await createStack({ mode: "docker", jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + 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", + }, postgres: { dataDir }, + postgrest: {}, + auth: {}, + edgeRuntime: {}, + realtime: {}, + storage: {}, + imgproxy: {}, + mailpit: {}, + pgmeta: {}, + studio: {}, analytics: {}, + vector: {}, + pooler: {}, }); try { @@ -65,16 +148,16 @@ dockerDescribe("createStack e2e (docker mode)", () => { apiPort = new URL(stack.url).port; supabase = createClient(stack.url, stack.publishableKey); - }, STACK_DOCKER_E2E_SETUP_TIMEOUT_MS); + }, STACK_DOCKER_E2E_FULL_START_TIMEOUT_MS); afterAll(async () => { await stack?.dispose(); - // Verify all Docker containers are cleaned up after dispose - const remaining = execSync(`docker ps -q --filter name=supabase-.*-${apiPort}`) - .toString() - .trim(); - expect(remaining).toBe(""); + // Verify all exact owned Docker containers are cleaned up, including stopped containers. + if (apiPort !== undefined) { + const remaining = ownedDockerContainers(apiPort).map((container) => container.name); + expect(remaining).toEqual([]); + } try { rmSync(dataDir, { recursive: true, force: true }); @@ -82,175 +165,322 @@ dockerDescribe("createStack e2e (docker mode)", () => { }, 30_000); test( - "runs the core services in Docker containers and serves health endpoints", + "qualifies the complete slim Docker graph through one public user journey", { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, async () => { - await Promise.all([stack.startService("postgrest"), stack.startService("auth")]); - - const runningImages = execSync("docker ps --format '{{.Image}}'").toString(); - expect(runningImages).toContain( - `ghcr.io/supabase/cli/postgrest:${DEFAULT_VERSIONS.postgrest}`, - ); - expect(runningImages).toContain(`ghcr.io/supabase/cli/postgres:${DEFAULT_VERSIONS.postgres}`); - expect(runningImages).toContain(`ghcr.io/supabase/cli/auth:${DEFAULT_VERSIONS.auth}`); - - const [proxyRes, authRes] = await Promise.all([ - fetch(`${stack.url}/health`), - fetch(`${stack.url}/auth/v1/health`), - ]); - expect(proxyRes.status).toBe(200); - expect(await proxyRes.text()).toBe("OK"); - expect(authRes.status).toBe(200); - expect(await authRes.json()).toEqual( - expect.objectContaining({ description: expect.any(String) }), - ); - }, - ); - - test( - "runs the edge runtime in Docker and serves the functions placeholder through the local gateway", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - const functionsRes = await fetch(`${stack.url}/functions/v1/test`); - await stack.serviceReady("edge-runtime"); - const runningImages = execSync("docker ps --format '{{.Image}}'").toString(); - const states = await stack.getStatus(); - - expect(runningImages).toContain( - `ghcr.io/supabase/cli/edge-runtime:${DEFAULT_VERSIONS["edge-runtime"]}`, - ); - expect(states).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: "edge-runtime", status: "Healthy" }), - ]), - ); - expect(functionsRes.status).toBe(501); - expect(await functionsRes.json()).toEqual({ - code: "FUNCTIONS_NOT_CONFIGURED", - message: "Edge Functions are not configured for this local stack yet.", - }); - }, - ); - - test( - "cold-starts analytics through lazy service activation", - { timeout: ANALYTICS_COLD_START_TEST_TIMEOUT_MS }, - async () => { - expect(await stack.getServiceStatus("analytics")).toEqual( - expect.objectContaining({ status: "Dormant" }), - ); - - await stack.startService("analytics"); - - const [runningImages, states] = await Promise.all([ - Promise.resolve(execSync("docker ps --format '{{.Image}}'").toString()), - stack.getStatus(), - ]); - - expect(runningImages).toContain( - `ghcr.io/supabase/cli/analytics:${DEFAULT_VERSIONS.analytics}`, - ); - expect(states).toEqual( - expect.arrayContaining([expect.objectContaining({ name: "analytics", status: "Healthy" })]), - ); - }, - ); + try { + const initialStates = await stack.getStatus(); + expect(initialStates.map((state) => state.name).toSorted()).toEqual( + [...SERVICE_NAMES].toSorted(), + ); - test( - "supports the docker auth signup and session golden path", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - const testEmail = `test-${Date.now()}@example.com`; - const testPassword = "test-password-123"; - - const signUp = await supabase.auth.signUp({ - email: testEmail, - password: testPassword, - }); - expect(signUp.error).toBeNull(); - expect(signUp.data.user?.email).toBe(testEmail); - expect(signUp.data.session).toBeDefined(); - - const signIn = await supabase.auth.signInWithPassword({ - email: testEmail, - password: testPassword, - }); - expect(signIn.error).toBeNull(); - expect(signIn.data.user?.email).toBe(testEmail); - expect(signIn.data.session?.access_token).toBeTruthy(); - - const currentUser = await supabase.auth.getUser(); - expect(currentUser.error).toBeNull(); - expect(currentUser.data.user?.email).toBe(testEmail); - }, - ); + const [storageRes, pgmetaRes, analyticsRes] = await Promise.all([ + fetch(`${stack.url}/storage/v1/status`), + fetch(`${stack.url}/pg/health`), + fetch(`${stack.url}/analytics/v1/health`), + ]); + expect(storageRes.status, "storage status").toBe(200); + expect(pgmetaRes.status, "pgmeta status").toBe(200); + expect(analyticsRes.status, "analytics status").toBe(200); + + const functionsRes = await fetch(`${stack.url}/functions/v1/test`); + expect(functionsRes.status).toBe(501); + expect(await functionsRes.json()).toEqual({ + code: "FUNCTIONS_NOT_CONFIGURED", + message: "Edge Functions are not configured for this local stack yet.", + }); - test( - "supports a full docker PostgREST CRUD golden path", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - const seeded = await supabase.from("todos").select("*").order("id"); - expect(seeded.error).toBeNull(); - expect(seeded.data).toHaveLength(2); - - const inserted = await supabase - .from("todos") - .insert({ title: "E2E test todo" }) - .select() - .single(); - expect(inserted.error).toBeNull(); - expect(inserted.data?.title).toBe("E2E test todo"); - - const updated = await supabase - .from("todos") - .update({ completed: true }) - .eq("title", "E2E test todo") - .select() - .single(); - expect(updated.error).toBeNull(); - expect(updated.data?.completed).toBe(true); - - const deleted = await supabase.from("todos").delete().eq("title", "E2E test todo"); - expect(deleted.error).toBeNull(); - - const remaining = await supabase.from("todos").select("*").eq("title", "E2E test todo"); - expect(remaining.data).toHaveLength(0); - }, - ); + const testEmail = `test-${Date.now()}@example.com`; + const testPassword = "test-password-123"; + const signUp = await supabase.auth.signUp({ + email: testEmail, + password: testPassword, + }); + expect(signUp.error).toBeNull(); + expect(signUp.data.user?.email).toBe(testEmail); + expect(signUp.data.session).toBeDefined(); - test( - "restarts the Studio graph with its Pgmeta dependency", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - const graphDataDir = mkdtempSync(join(tmpdir(), "supabase-e2e-docker-graph-")); - let graphStack: StackHandle | undefined; - try { - graphStack = await createStack({ - mode: "docker", - postgres: { dataDir: graphDataDir }, - pgmeta: {}, - studio: {}, + const signIn = await supabase.auth.signInWithPassword({ + email: testEmail, + password: testPassword, }); - await graphStack.start(); - expect(await graphStack.getServiceStatus("pgmeta")).toEqual( - expect.objectContaining({ status: "Healthy" }), + expect(signIn.error).toBeNull(); + expect(signIn.data.user?.email).toBe(testEmail); + expect(signIn.data.session?.access_token).toBeTruthy(); + + const currentUser = await supabase.auth.getUser(); + expect(currentUser.error).toBeNull(); + expect(currentUser.data.user?.email).toBe(testEmail); + + const seeded = await supabase.from("todos").select("*").order("id"); + expect(seeded.error).toBeNull(); + expect(seeded.data).toHaveLength(2); + + const todoTitle = `E2E test todo ${Date.now()}`; + const inserted = await supabase + .from("todos") + .insert({ title: todoTitle }) + .select() + .single(); + expect(inserted.error).toBeNull(); + expect(inserted.data?.title).toBe(todoTitle); + + const updated = await supabase + .from("todos") + .update({ completed: true }) + .eq("title", todoTitle) + .select() + .single(); + expect(updated.error).toBeNull(); + expect(updated.data?.completed).toBe(true); + + const healthyStates = await stack.getStatus(); + expect(healthyStates).toHaveLength(SERVICE_NAMES.length); + expect(healthyStates.every((state) => state.status === "Healthy")).toBe(true); + + const ownedContainers = ownedDockerContainers(apiPort); + expect(ownedContainers.map((container) => container.name).toSorted()).toEqual( + SERVICE_NAMES.map((service) => ownedDockerContainerName(service, apiPort)).toSorted(), ); - expect(await graphStack.getServiceStatus("studio")).toEqual( - expect.objectContaining({ status: "Healthy" }), + for (const service of SERVICE_NAMES) { + const containerName = ownedDockerContainerName(service, apiPort); + const container = ownedContainers.find((candidate) => candidate.name === containerName); + expect(container?.image, `${service} image`).toBe(EXPECTED_SLIM_DOCKER_IMAGES[service]); + } + + const primaryOwnedNamesBeforeSibling = ownedContainers.map((container) => container.name); + const primaryDbPort = new URL(stack.dbUrl).port; + const primaryIsolationTitle = "isolation-primary"; + const siblingIsolationTitle = "isolation-sibling"; + const primaryIsolationInsert = await supabase + .from("todos") + .insert({ title: primaryIsolationTitle }) + .select() + .single(); + expect(primaryIsolationInsert.error).toBeNull(); + expect(primaryIsolationInsert.data?.title).toBe(primaryIsolationTitle); + + const siblingDataDir = mkdtempSync(join(tmpdir(), "supabase-e2e-docker-sibling-")); + const siblingStartedServices: ReadonlyArray = [...EAGER_SERVICES, "postgrest"]; + let sibling: StackHandle | undefined; + let siblingApiPort: string | undefined; + let siblingJourneyError: unknown; + let siblingDisposeError: unknown; + let siblingContainerCleanupError: unknown; + let siblingDataDirCleanupError: unknown; + try { + sibling = await createStack({ + mode: "docker", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + 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", + }, + postgres: { dataDir: siblingDataDir }, + postgrest: {}, + auth: {}, + edgeRuntime: {}, + realtime: {}, + storage: {}, + imgproxy: {}, + mailpit: {}, + pgmeta: {}, + studio: {}, + analytics: {}, + vector: {}, + pooler: {}, + }); + const siblingIdentity = new URL(sibling.url).port; + siblingApiPort = siblingIdentity; + await sibling.start(); + + const siblingDbPort = new URL(sibling.dbUrl).port; + expect(siblingIdentity).not.toBe(apiPort); + expect(siblingDbPort).not.toBe(primaryDbPort); + await setupTestTable(parseInt(siblingDbPort)); + const siblingSupabase = createClient(sibling.url, sibling.publishableKey); + await sibling.startService("postgrest"); + + const [primaryBeforeDispose, siblingStates] = await Promise.all([ + stack.getStatus(), + sibling.getStatus(), + ]); + expect(primaryBeforeDispose.map((state) => state.name).toSorted()).toEqual( + [...SERVICE_NAMES].toSorted(), + ); + expect(primaryBeforeDispose.every((state) => state.status === "Healthy")).toBe(true); + expect(primaryBeforeDispose.map(({ name, status }) => ({ name, status }))).toEqual( + healthyStates.map(({ name, status }) => ({ name, status })), + ); + expect(siblingStates.map((state) => state.name).toSorted()).toEqual( + [...SERVICE_NAMES].toSorted(), + ); + expect(siblingStates).toEqual( + expect.arrayContaining( + siblingStartedServices.map((name) => + expect.objectContaining({ name, status: "Healthy" }), + ), + ), + ); + expect(siblingStates).toEqual( + expect.arrayContaining( + LAZY_SERVICES.filter((name) => name !== "postgrest").map((name) => + expect.objectContaining({ name, status: "Dormant" }), + ), + ), + ); + + const siblingIsolationInsert = await siblingSupabase + .from("todos") + .insert({ title: siblingIsolationTitle }) + .select() + .single(); + expect(siblingIsolationInsert.error).toBeNull(); + expect(siblingIsolationInsert.data?.title).toBe(siblingIsolationTitle); + + const [siblingCannotReadPrimary, primaryCannotReadSibling] = await Promise.all([ + siblingSupabase.from("todos").select("title").eq("title", primaryIsolationTitle), + supabase.from("todos").select("title").eq("title", siblingIsolationTitle), + ]); + expect(siblingCannotReadPrimary.error).toBeNull(); + expect(siblingCannotReadPrimary.data).toEqual([]); + expect(primaryCannotReadSibling.error).toBeNull(); + expect(primaryCannotReadSibling.data).toEqual([]); + + const siblingOwnedBeforeDispose = ownedDockerContainers(siblingIdentity); + expect(siblingOwnedBeforeDispose.map((container) => container.name).toSorted()).toEqual( + siblingStartedServices + .map((service) => ownedDockerContainerName(service, siblingIdentity)) + .toSorted(), + ); + } catch (error) { + siblingJourneyError = error; + let siblingStates: ReadonlyArray = []; + let siblingLogs: ReadonlyArray = []; + if (sibling !== undefined) { + const activeSibling = sibling; + [siblingStates, siblingLogs] = await Promise.all([ + activeSibling.getStatus().catch(() => []), + Promise.all( + SERVICE_NAMES.map((service) => + activeSibling.logHistory(service, 10).catch(() => []), + ), + ), + ]); + } + siblingJourneyError = new Error( + `Sibling Docker isolation journey failed: ${String(error)}\nstatus=${JSON.stringify(siblingStates)}\nlogs=${JSON.stringify(siblingLogs)}`, + ); + } finally { + try { + if (sibling !== undefined) { + await sibling.dispose(); + } + } catch (error) { + siblingDisposeError = error; + } + + try { + if (siblingApiPort !== undefined) { + const remaining = ownedDockerContainers(siblingApiPort).map( + (container) => container.name, + ); + expect(remaining).toEqual([]); + } + } catch (error) { + siblingContainerCleanupError = error; + } + + try { + rmSync(siblingDataDir, { recursive: true, force: true }); + } catch (error) { + siblingDataDirCleanupError = error; + } + } + + if (siblingJourneyError !== undefined) { + throw siblingJourneyError; + } + if (siblingDisposeError !== undefined) { + throw siblingDisposeError; + } + if (siblingContainerCleanupError !== undefined) { + throw siblingContainerCleanupError; + } + if (siblingDataDirCleanupError !== undefined) { + throw siblingDataDirCleanupError; + } + + expect( + ownedDockerContainers(apiPort) + .map((container) => container.name) + .toSorted(), + ).toEqual(primaryOwnedNamesBeforeSibling.toSorted()); + const primaryAfterSiblingDispose = await supabase + .from("todos") + .select("title") + .eq("title", primaryIsolationTitle) + .single(); + expect(primaryAfterSiblingDispose.error).toBeNull(); + expect(primaryAfterSiblingDispose.data?.title).toBe(primaryIsolationTitle); + const deletedIsolation = await supabase + .from("todos") + .delete() + .eq("title", primaryIsolationTitle); + expect(deletedIsolation.error).toBeNull(); + + const beforeRestart = await stack.getStatus(); + await stack.stop(); + await stack.start(); + const afterRestart = await stack.getStatus(); + expect(afterRestart.map((state) => state.name).toSorted()).toEqual( + beforeRestart.map((state) => state.name).toSorted(), ); - - await graphStack.stop(); - await graphStack.start(); - - expect(await graphStack.getServiceStatus("pgmeta")).toEqual( - expect.objectContaining({ status: "Healthy" }), + expect(afterRestart).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "pgmeta", status: "Healthy" }), + expect.objectContaining({ name: "studio", status: "Healthy" }), + ]), + ); + expect(afterRestart).toEqual( + expect.arrayContaining( + EAGER_SERVICES.map((name) => expect.objectContaining({ name, status: "Healthy" })), + ), + ); + expect(afterRestart).toEqual( + expect.arrayContaining( + LAZY_SERVICES.map((name) => expect.objectContaining({ name, status: "Stopped" })), + ), ); - expect(await graphStack.getServiceStatus("studio")).toEqual( - expect.objectContaining({ status: "Healthy" }), + await stack.startService("postgrest"); + + const persisted = await supabase.from("todos").select("*").eq("title", todoTitle).single(); + expect(persisted.error).toBeNull(); + expect(persisted.data?.completed).toBe(true); + + const deleted = await supabase.from("todos").delete().eq("title", todoTitle); + expect(deleted.error).toBeNull(); + } catch (error) { + const [states, logs] = await Promise.all([ + stack.getStatus().catch(() => []), + Promise.all( + SERVICE_NAMES.map((service) => stack.logHistory(service, 10).catch(() => [])), + ), + ]); + throw new Error( + `Complete Docker graph journey failed: ${String(error)}\nstatus=${JSON.stringify(states)}\nlogs=${JSON.stringify(logs)}`, ); - } finally { - await graphStack?.dispose(); - rmSync(graphDataDir, { recursive: true, force: true }); } }, ); 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..cd3fce4f25 --- /dev/null +++ b/packages/stack/tests/createStack-native-services.e2e.test.ts @@ -0,0 +1,613 @@ +// 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, createServer, 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 ResolvedFunctionsBundle, + type StackHandle, +} from "@supabase/stack"; +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 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; +}; + +const bindAndClose = async (port: number): Promise => { + const server = createServer(); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(port, "127.0.0.1", () => { + server.removeListener("error", onError); + server.close((error) => (error === undefined ? resolve() : reject(error))); + }); + }); +}; + +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, 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 after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +}; + +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 vectorAdminPort: number | undefined; + let analyticsDirectPort: number | undefined; + + 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: "eager", + 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: {}, + }); + + try { + await stack.start(); + } catch (error) { + await stack.dispose().catch(() => {}); + throw error; + } + supabase = createClient(stack.url, stack.publishableKey); + await setupTestTable(Number(new URL(stack.dbUrl).port)); + const vectorConfig = readFileSync(join(runtimeRoot, "vector", "vector.yaml"), "utf8"); + const vectorPortMatch = /address:\s*"127\.0\.0\.1:(\d+)"/.exec(vectorConfig); + if (vectorPortMatch === null) throw new Error("native Vector admin port is missing"); + vectorAdminPort = Number(vectorPortMatch[1]); + const analyticsPortMatch = /uri:\s*"http:\/\/127\.0\.0\.1:(\d+)\/api\/logs/.exec(vectorConfig); + if (analyticsPortMatch === null) + throw new Error("native Analytics port is missing from Vector config"); + analyticsDirectPort = Number(analyticsPortMatch[1]); + }, 1_200_000); + + afterAll(async () => { + try { + const ownedPorts = new Set(); + if (stack !== undefined) { + for (const endpoint of Object.values(stack.serviceEndpoints)) { + ownedPorts.add(endpointPort(endpoint)); + } + ownedPorts.add(endpointPort(stack.url)); + ownedPorts.add(endpointPort(stack.dbUrl)); + } + if (vectorAdminPort !== undefined) ownedPorts.add(vectorAdminPort); + if (analyticsDirectPort !== undefined) ownedPorts.add(analyticsDirectPort); + if (stack !== undefined) { + const states = await stack.getStatus().catch(() => []); + ownedPids = states.flatMap((state) => (state.pid === null ? [] : [state.pid])); + await stack.dispose(); + 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([]); + for (const port of ownedPorts) { + await bindAndClose(port); + } + } 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 () => { + 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", + "realtime", + "mailpit", + "pgmeta", + "studio", + "analytics", + "vector", + "pooler", + ]), + ); + expect(existsSync(sentinelMarker)).toBe(false); + + const authSettings = await fetch(`${stack.url}/auth/v1/settings`, { + headers: { apikey: stack.publishableKey }, + }); + expect(authSettings.status).toBe(200); + await authSettings.arrayBuffer(); + + const functionResponse = await fetchFunctionWhenReady( + `${stack.url}/functions/v1/native-services`, + ); + expect(functionResponse.status).toBe(200); + expect(await functionResponse.text()).toBe("native services ready"); + + const postgrestTable = await supabase.from("todos").select("id").limit(1); + expect(postgrestTable.error).toBeNull(); + + 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(); + } + + const realtime = createClient(stack.url, stack.publishableKey); + let realtimeChannel: ReturnType | undefined; + const realtimeChange = new Promise((resolve, reject) => { + realtimeChannel = realtime + .channel("native-services-todos") + .on( + "postgres_changes", + { event: "INSERT", schema: "public", table: "todos" }, + (payload) => resolve(payload), + ) + .subscribe((status) => { + if (status !== "SUBSCRIBED") { + if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") { + reject(new Error(`Realtime subscription status: ${status}`)); + } + return; + } + const insert = new Bun.SQL(stack.dbUrl); + void insert + .unsafe( + `INSERT INTO public.todos (title, completed) VALUES ('native-realtime', false)`, + ) + .then(() => insert.close()) + .catch(async (error: unknown) => { + await insert.close(); + reject(error instanceof Error ? error : new Error(String(error))); + }); + }); + }); + try { + const change = await withTimeout(realtimeChange); + expect(change).toEqual( + expect.objectContaining({ + eventType: "INSERT", + table: "todos", + schema: "public", + }), + ); + } finally { + if (realtimeChannel !== undefined) await realtime.removeChannel(realtimeChannel); + } + + const bucket = `native-services-${Date.now()}`; + const createdBucket = await supabase.storage.createBucket(bucket, { public: true }); + expect(createdBucket.error).toBeNull(); + const uploaded = await supabase.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); + + 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(); + + 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); + + 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(); + } + + if (analyticsDirectPort === undefined) + throw new Error("native Analytics port is unavailable"); + const sourcesResponse = await fetch(`http://127.0.0.1:${analyticsDirectPort}/api/sources`, { + headers: { "x-api-key": "native-services-analytics-key" }, + }); + expect(sourcesResponse.status).toBe(200); + const sourcesBody = await readJson(sourcesResponse); + const sources = Array.isArray(sourcesBody) + ? sourcesBody + : typeof sourcesBody === "object" && sourcesBody !== null + ? Reflect.get(sourcesBody, "sources") + : undefined; + if (!Array.isArray(sources)) throw new Error("Analytics source list is not an array"); + const postgresLogs = sources.find( + (source: unknown) => + typeof source === "object" && + source !== null && + Reflect.get(source, "name") === "postgres.logs", + ); + if (typeof postgresLogs !== "object" || postgresLogs === null) { + throw new Error("Analytics postgres.logs source is missing"); + } + const sourceToken = Reflect.get(postgresLogs, "token"); + if (typeof sourceToken !== "string" || !/^[0-9a-f-]+$/i.test(sourceToken)) { + throw new Error("Analytics postgres.logs source token is invalid"); + } + const physicalTable = `log_events_${sourceToken.replaceAll("-", "_")}`; + 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 notificationSubscription: Awaited> | undefined; + let notificationResolve: ((payload: string) => void) | undefined; + try { + 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); + }); + 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); + expect(observedNotification).toContain(EDGE_LOG_MARKER); + const recentResponse = await fetch( + `http://127.0.0.1:${analyticsDirectPort}/api/sources/${sourceToken}/recent`, + { headers: { "x-api-key": "native-services-analytics-key" } }, + ); + expect(recentResponse.status).toBe(200); + expect(JSON.stringify(await readJson(recentResponse))).toContain(EDGE_LOG_MARKER); + } finally { + await notificationSubscription?.unlisten(); + await analyticsSql + .unsafe( + `DROP TRIGGER IF EXISTS "${triggerName}" ON _analytics."${physicalTable}"; DROP FUNCTION IF EXISTS _analytics."${functionName}"();`, + ) + .catch(() => {}); + await analyticsSql.close(); + } + + const mailpitStream = stack.serviceLogs("mailpit")[Symbol.asyncIterator](); + const observedLog = mailpitStream.next(); + await stack.restartService("mailpit"); + const logEvent = await withTimeout(observedLog); + 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:"); + }, + ); +}); diff --git a/packages/stack/tests/createStack-native.e2e.test.ts b/packages/stack/tests/createStack-native.e2e.test.ts index 5ac31fa5c6..ceacefd704 100644 --- a/packages/stack/tests/createStack-native.e2e.test.ts +++ b/packages/stack/tests/createStack-native.e2e.test.ts @@ -1,59 +1,417 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- Native e2e tests await subprocess-backed stack operations and use filesystem/path fixtures. +// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-fetch, effecttsgo/new-promise, effecttsgo/node-builtin-import, effecttsgo/process-env -- Native e2e tests await subprocess-backed stack operations and use filesystem/path fixtures. import { createClient } from "@supabase/supabase-js"; -import { mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:net"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { Predicate } from "effect"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { createStack, type StackHandle } from "../src/node.ts"; -import { defaultCacheRoot } from "../src/paths.ts"; +import { createStack, prefetch, StackError, type StackHandle } from "@supabase/stack"; import { setupTestTable } from "./helpers/e2e.ts"; +const activateWithoutDownload = async ( + stack: StackHandle, + service: string, + activate: () => Promise, +): Promise => { + const iterator = stack.statusChanges()[Symbol.asyncIterator](); + const first = iterator.next(); + const observed: string[] = []; + try { + await activate(); + let event = await first; + while (!event.done) { + if (event.value.name === service) { + observed.push(event.value.status); + if (event.value.status === "Healthy") break; + } + event = await iterator.next(); + } + } finally { + await iterator.return?.(); + } + expect(observed).toContain("Healthy"); + expect(observed).not.toContain("Downloading"); +}; + +const drain = async (response: Response): Promise => { + await response.arrayBuffer(); +}; + +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 bindAndClose = async (port: number): Promise => { + const server = createServer(); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(port, "127.0.0.1", () => { + server.removeListener("error", onError); + server.close((error) => (error === undefined ? resolve() : reject(error))); + }); + }); +}; + +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; +}; + +const markerValue = (path: string, key: string): unknown => { + const value: unknown = JSON.parse(readFileSync(path, "utf8")); + return typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined; +}; + describe("native PostgREST tracer bullet", () => { const jwtSecret = "native-e2e-jwt-secret-with-at-least-32-characters"; let stack: StackHandle; let dataDir: string; - let cacheParent: string; + let cacheRoot: string; + let authCachePath: string; + let postgresCachePath: string; + let postgrestCachePath: string; + let stackRoot: string; + let runtimeRoot: string; + let sentinelBin: string; + let sentinelMarker: string; + let originalPath: string | undefined; + let stackDisposed = false; beforeAll(async () => { dataDir = mkdtempSync(join(tmpdir(), "supabase-native-postgrest-e2e-")); - cacheParent = mkdtempSync(join(tmpdir(), "supabase-native-cache-parent-")); - const cacheRoot = join(cacheParent, "cache root with spaces"); - symlinkSync(defaultCacheRoot(), cacheRoot, "dir"); + cacheRoot = mkdtempSync(join(tmpdir(), "supabase-native-cache-")); + stackRoot = mkdtempSync(join(tmpdir(), "supabase-native-stack-root-")); + runtimeRoot = mkdtempSync(join(tmpdir(), "supabase-native-runtime-root-")); + sentinelBin = mkdtempSync(join(tmpdir(), "supabase-native-runtime-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(":"); stack = await createStack({ mode: "native", cacheRoot, + stackRoot, + runtimeRoot, functions: false, edgeRuntime: false, - auth: false, jwtSecret, postgres: { dataDir }, }); await stack.start(); + expect(existsSync(join(cacheRoot, "bin", "slim-services", "auth"))).toBe(false); + expect(existsSync(join(cacheRoot, "bin", "slim-services", "postgrest"))).toBe(false); + expect(existsSync(sentinelMarker)).toBe(false); + + const warmed = await prefetch({ + mode: "native", + cacheRoot, + services: ["auth", "postgrest"], + }); + expect(warmed.postgres?.type).toBe("binary"); + expect(warmed.auth?.type).toBe("binary"); + expect(warmed.postgrest?.type).toBe("binary"); + expect(warmed.postgres?.type === "binary" && warmed.postgres.path.startsWith(cacheRoot)).toBe( + true, + ); + expect(warmed.auth?.type === "binary" && warmed.auth.path.startsWith(cacheRoot)).toBe(true); + expect(warmed.postgrest?.type === "binary" && warmed.postgrest.path.startsWith(cacheRoot)).toBe( + true, + ); + if (warmed.auth?.type !== "binary") throw new Error("native Auth was not prefetched"); + if (warmed.postgres?.type !== "binary") throw new Error("native PostgreSQL was not prefetched"); + if (warmed.postgrest?.type !== "binary") throw new Error("native PostgREST was not prefetched"); + authCachePath = warmed.auth.path; + postgresCachePath = warmed.postgres.path; + postgrestCachePath = warmed.postgrest.path; + expect(existsSync(sentinelMarker)).toBe(false); await setupTestTable(parseInt(new URL(stack.dbUrl).port)); - }, 45_000); + }, 180_000); afterAll(async () => { - await stack?.dispose(); - rmSync(dataDir, { recursive: true, force: true }); - rmSync(cacheParent, { recursive: true, force: true }); + try { + if (!stackDisposed) await stack?.dispose(); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + if (dataDir !== undefined) rmSync(dataDir, { recursive: true, force: true }); + if (cacheRoot !== undefined) rmSync(cacheRoot, { recursive: true, force: true }); + if (stackRoot !== undefined) rmSync(stackRoot, { recursive: true, force: true }); + if (runtimeRoot !== undefined) rmSync(runtimeRoot, { recursive: true, force: true }); + if (sentinelBin !== undefined) rmSync(sentinelBin, { recursive: true, force: true }); + } + }, 120_000); + + test("keeps lazy Auth and PostgREST dormant until their first requests", async () => { + const statuses = await stack.getStatus(); + expect(statuses.find((state) => state.name === "postgres")?.status).toBe("Healthy"); + expect(statuses.find((state) => state.name === "auth")?.status).toBe("Dormant"); + expect(statuses.find((state) => state.name === "postgrest")?.status).toBe("Dormant"); + }, 30_000); + + test("retries Auth signup and password sessions after a corrected JIT preparation failure", async () => { + const markerPath = join(authCachePath, ".complete"); + const marker = readFileSync(markerPath); + const asset = markerValue(markerPath, "asset"); + if (typeof asset !== "string") throw new Error("native Auth marker has no asset name"); + const authParent = dirname(authCachePath); + const movedParent = `${authParent}.fault`; + const stalePartial = join(authParent, `.${asset}.partial-stale`); + const staleLock = join(authParent, `.${asset}.publication-lock`); + mkdirSync(stalePartial, { recursive: true }); + mkdirSync(staleLock, { recursive: true }); + utimesSync(stalePartial, new Date(0), new Date(0)); + utimesSync(staleLock, new Date(0), new Date(0)); + rmSync(movedParent, { recursive: true, force: true }); + renameSync(authParent, movedParent); + writeFileSync(authParent, "native-auth-preparation-fault", "utf8"); + try { + const failedRequest = await fetch(`${stack.url}/auth/v1/settings`, { + headers: { apikey: stack.publishableKey }, + }); + expect(failedRequest.status).toBe(503); + await drain(failedRequest); + + let failure: unknown; + try { + await stack.startService("auth"); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(StackError); + if (!(failure instanceof StackError)) + throw new Error("Auth preparation did not fail publicly"); + expect(failure.code).toBe("BUILD_ERROR"); + expect(Predicate.isTagged(failure.cause, "StackBuildError")).toBe(true); + expect(await stack.getServiceStatus("postgres")).toMatchObject({ status: "Healthy" }); + expect(await stack.getServiceStatus("auth")).toMatchObject({ status: "Dormant" }); + } finally { + rmSync(authParent, { force: true }); + renameSync(movedParent, authParent); + } + + const authEmail = `native-${Date.now()}@example.com`; + const authPassword = "native-password-123"; + const client = createClient(stack.url, stack.publishableKey); + await activateWithoutDownload(stack, "auth", async () => { + const signup = await client.auth.signUp({ email: authEmail, password: authPassword }); + expect(signup.error).toBeNull(); + expect(signup.data.user?.email).toBe(authEmail); + expect(signup.data.session).not.toBeNull(); + }); + await client.auth.signOut(); + const signIn = await client.auth.signInWithPassword({ + email: authEmail, + password: authPassword, + }); + expect(signIn.error).toBeNull(); + expect(signIn.data.user?.email).toBe(authEmail); + expect(signIn.data.session).not.toBeNull(); + expect(readFileSync(markerPath)).toEqual(marker); + expect(existsSync(stalePartial)).toBe(false); + expect(existsSync(staleLock)).toBe(false); + expect(existsSync(sentinelMarker)).toBe(false); }, 30_000); + test("starts eager native Auth and PostgREST before reporting readiness", async () => { + const eagerDataDir = mkdtempSync(join(tmpdir(), "supabase-native-eager-data-")); + const eager = await createStack({ + mode: "native", + cacheRoot, + functions: false, + edgeRuntime: false, + servicePolicies: { auth: "eager", postgrest: "eager" }, + jwtSecret, + postgres: { dataDir: eagerDataDir }, + }); + try { + await eager.start(); + const statuses = await eager.getStatus(); + expect(statuses.find((state) => state.name === "postgres")?.status).toBe("Healthy"); + expect(statuses.find((state) => state.name === "auth")?.status).toBe("Healthy"); + expect(statuses.find((state) => state.name === "postgrest")?.status).toBe("Healthy"); + } finally { + await eager.dispose(); + rmSync(eagerDataDir, { recursive: true, force: true }); + } + expect(existsSync(sentinelMarker)).toBe(false); + }, 60_000); + test("serves a CRUD request through the native PostgREST resource", async () => { const client = createClient(stack.url, stack.publishableKey); - const inserted = await client + let inserted: { id: number; title: string; completed: boolean } | undefined; + await activateWithoutDownload(stack, "postgrest", async () => { + const result = await client + .from("todos") + .insert({ title: "native tracer bullet" }) + .select() + .single(); + expect(result.error).toBeNull(); + expect(result.data).toEqual(expect.objectContaining({ title: "native tracer bullet" })); + if (result.data === null) throw new Error("PostgREST insert returned no row"); + inserted = result.data; + }); + if (inserted === undefined) throw new Error("PostgREST insert did not produce a row"); + + const read = await client.from("todos").select().eq("id", inserted.id).single(); + expect(read.error).toBeNull(); + expect(read.data).toEqual(inserted); + + const updated = await client .from("todos") - .insert({ title: "native tracer bullet" }) + .update({ completed: !inserted.completed }) + .eq("id", inserted.id) .select() .single(); + expect(updated.error).toBeNull(); + expect(updated.data).toEqual({ ...inserted, completed: !inserted.completed }); - expect(inserted.error).toBeNull(); - expect(inserted.data).toEqual(expect.objectContaining({ title: "native tracer bullet" })); + const updatedRead = await client.from("todos").select().eq("id", inserted.id).single(); + expect(updatedRead.error).toBeNull(); + expect(updatedRead.data).toEqual(updated.data); - const deleted = await client.from("todos").delete().eq("title", "native tracer bullet"); + const deleted = await client.from("todos").delete().eq("id", inserted.id).select().single(); expect(deleted.error).toBeNull(); + expect(deleted.data).toEqual(updated.data); + + const afterDelete = await client.from("todos").select().eq("id", inserted.id); + expect(afterDelete.error).toBeNull(); + expect(afterDelete.data).toEqual([]); + }, 30_000); + + test("exposes launch-scope PostgreSQL extensions through real SQL behavior", async () => { + const sql = new Bun.SQL(stack.dbUrl); + try { + const rows = await sql.unsafe< + { + uuid: string; + randomUuid: string; + statements: number; + }[] + >(` + SELECT + extensions.uuid_generate_v4()::text AS uuid, + extensions.gen_random_uuid()::text AS "randomUuid", + (SELECT count(*)::int FROM extensions.pg_stat_statements(false)) AS statements; + `); + expect(rows[0]?.uuid).toMatch(/^[0-9a-f-]{36}$/); + expect(rows[0]?.randomUuid).toMatch(/^[0-9a-f-]{36}$/); + expect(rows[0]?.statements).toBeGreaterThan(0); + } finally { + await sql.close(); + } }, 30_000); + test("preserves native data, endpoints, policies, and cache identity across restart", async () => { + const authResponse = await fetch(`${stack.url}/auth/v1/settings`, { + headers: { apikey: stack.publishableKey }, + }); + expect(authResponse.status).toBe(200); + await drain(authResponse); + const postgrestResponse = await fetch(`${stack.url}/rest/v1/todos?select=id&limit=1`, { + headers: { apikey: stack.publishableKey }, + }); + expect(postgrestResponse.status).toBe(200); + await drain(postgrestResponse); + expect(await stack.getServiceStatus("auth")).toMatchObject({ status: "Healthy" }); + expect(await stack.getServiceStatus("postgrest")).toMatchObject({ status: "Healthy" }); + + const markerPaths = [authCachePath, postgresCachePath, postgrestCachePath].map((path) => + join(path, ".complete"), + ); + const identityKeys = ["runtime", "releaseSet", "service", "version", "target"]; + const identitiesBefore = markerPaths.map((path) => + identityKeys.map((key) => markerValue(path, key)), + ); + for (const path of markerPaths) expect(markerValue(path, "runtime")).toBe("native"); + const before = { + url: stack.url, + dbUrl: stack.dbUrl, + manifests: markerPaths.map((path) => readFileSync(path, "utf8")), + }; + const sql = new Bun.SQL(stack.dbUrl); + try { + await sql.unsafe( + `INSERT INTO public.todos (title, completed) VALUES ('native restart persistence', true)`, + ); + } finally { + await sql.close(); + } + + await stack.stop(); + await stack.stop(); + await stack.start(); + + expect(stack.url).toBe(before.url); + expect(stack.dbUrl).toBe(before.dbUrl); + expect(markerPaths.map((path) => readFileSync(path, "utf8"))).toEqual(before.manifests); + expect(markerPaths.map((path) => identityKeys.map((key) => markerValue(path, key)))).toEqual( + identitiesBefore, + ); + const check = new Bun.SQL(stack.dbUrl); + try { + const rows = await check.unsafe<{ title: string }[]>( + `SELECT title FROM public.todos WHERE title = 'native restart persistence'`, + ); + expect(rows).toHaveLength(1); + expect(rows[0]?.title).toBe("native restart persistence"); + } finally { + await check.close(); + } + const statuses = await stack.getStatus(); + expect(statuses.find((state) => state.name === "postgres")?.status).toBe("Healthy"); + expect(statuses.find((state) => state.name === "auth")?.status).toBe("Stopped"); + expect(statuses.find((state) => state.name === "postgrest")?.status).toBe("Stopped"); + + await activateWithoutDownload(stack, "auth", async () => { + const response = await fetch(`${stack.url}/auth/v1/settings`, { + headers: { apikey: stack.publishableKey }, + }); + expect(response.status).toBe(200); + await drain(response); + }); + await activateWithoutDownload(stack, "postgrest", async () => { + const response = await fetch(`${stack.url}/rest/v1/todos?select=id&limit=1`, { + headers: { apikey: stack.publishableKey }, + }); + expect(response.status).toBe(200); + await drain(response); + }); + expect(await stack.getServiceStatus("auth")).toMatchObject({ status: "Healthy" }); + expect(await stack.getServiceStatus("postgrest")).toMatchObject({ status: "Healthy" }); + expect(existsSync(sentinelMarker)).toBe(false); + }, 60_000); + test("persists JWT settings in the native Postgres database", async () => { const sql = new Bun.SQL(stack.dbUrl); try { @@ -103,4 +461,56 @@ describe("native PostgREST tracer bullet", () => { await check.close(); } }, 30_000); + + test("disposes exact native resources without deleting completed cache or data", async () => { + await activateWithoutDownload(stack, "auth", async () => { + const response = await fetch(`${stack.url}/auth/v1/settings`, { + headers: { apikey: stack.publishableKey }, + }); + expect(response.status).toBe(200); + await drain(response); + }); + await activateWithoutDownload(stack, "postgrest", async () => { + const response = await fetch(`${stack.url}/rest/v1/todos?select=id&limit=1`, { + headers: { apikey: stack.publishableKey }, + }); + expect(response.status).toBe(200); + await drain(response); + }); + + const healthyStates = await stack.getStatus(); + const ownedPids = ["postgres", "auth", "postgrest"].map((name) => { + const state = healthyStates.find((entry) => entry.name === name); + expect(state?.status).toBe("Healthy"); + if (state?.pid === null || state?.pid === undefined) { + throw new Error(`${name} did not publish a process id while healthy`); + } + return state.pid; + }); + const apiPort = Number(new URL(stack.url).port); + const dbPort = Number(new URL(stack.dbUrl).port); + expect(Number.isInteger(apiPort)).toBe(true); + expect(Number.isInteger(dbPort)).toBe(true); + + await stack.dispose(); + stackDisposed = true; + expect(ownedPids.every((pid) => !isProcessAlive(pid))).toBe(true); + await bindAndClose(apiPort); + await bindAndClose(dbPort); + expect(stagingEntries(cacheRoot)).toEqual([]); + expect( + [authCachePath, postgresCachePath, postgrestCachePath].every((path) => + existsSync(join(path, ".complete")), + ), + ).toBe(true); + expect(existsSync(dataDir)).toBe(true); + expect(existsSync(stackRoot)).toBe(true); + expect(existsSync(runtimeRoot)).toBe(true); + expect(existsSync(sentinelMarker)).toBe(false); + + rmSync(stackRoot, { recursive: true, force: true }); + rmSync(runtimeRoot, { recursive: true, force: true }); + expect(existsSync(stackRoot)).toBe(false); + expect(existsSync(runtimeRoot)).toBe(false); + }, 60_000); });